mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -0,0 +1,64 @@
|
||||
"""Add provider_type and expand api_key column to TEXT
|
||||
|
||||
- Add providers.provider_type (String(20), server_default="custom")
|
||||
- Change provider_api_keys.api_key from VARCHAR(500) to TEXT (OAuth tokens can be long)
|
||||
|
||||
Revision ID: b5c6d7e8f9a0
|
||||
Revises: c4e8f9a1b2c3
|
||||
Create Date: 2026-02-04 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b5c6d7e8f9a0"
|
||||
down_revision: Union[str, None] = "c4e8f9a1b2c3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否已存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add providers.provider_type
|
||||
if not column_exists("providers", "provider_type"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
|
||||
)
|
||||
|
||||
# Expand provider_api_keys.api_key from VARCHAR(500) to TEXT
|
||||
op.alter_column(
|
||||
"provider_api_keys",
|
||||
"api_key",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(500),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert provider_api_keys.api_key from TEXT to VARCHAR(500)
|
||||
# WARNING: Downgrade may fail if any api_key values exceed 500 characters
|
||||
op.alter_column(
|
||||
"provider_api_keys",
|
||||
"api_key",
|
||||
type_=sa.String(500),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# Drop providers.provider_type
|
||||
if column_exists("providers", "provider_type"):
|
||||
op.drop_column("providers", "provider_type")
|
||||
@@ -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'
|
||||
|
||||
@@ -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 格式的成本倍率
|
||||
|
||||
51
frontend/src/api/endpoints/provider_oauth.ts
Normal file
51
frontend/src/api/endpoints/provider_oauth.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
// OAuth:token 由 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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,9 @@ dev = [
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
tls = [
|
||||
"tls-client>=1.0.1", # 可选:用于 Claude OAuth token 请求的 TLS 指纹伪装
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/fawney19/Aether"
|
||||
|
||||
@@ -13,6 +13,7 @@ from .monitoring import router as monitoring_router
|
||||
from .provider_ops import router as provider_ops_router
|
||||
from .provider_query import router as provider_query_router
|
||||
from .provider_strategy import router as provider_strategy_router
|
||||
from .provider_oauth import router as provider_oauth_router
|
||||
from .providers import router as providers_router
|
||||
from .security import router as security_router
|
||||
from .stats import router as stats_router
|
||||
@@ -31,6 +32,7 @@ router.include_router(usage_router)
|
||||
router.include_router(monitoring_router)
|
||||
router.include_router(endpoints_router)
|
||||
router.include_router(provider_strategy_router)
|
||||
router.include_router(provider_oauth_router)
|
||||
router.include_router(adaptive_router)
|
||||
router.include_router(models_router)
|
||||
router.include_router(security_router)
|
||||
|
||||
@@ -241,11 +241,11 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
# auth_type 切换校验 + 字段归一化
|
||||
if "auth_type" in update_data:
|
||||
if target_auth_type == "api_key":
|
||||
if current_auth_type == "vertex_ai" and not update_data.get("api_key"):
|
||||
if current_auth_type in {"vertex_ai", "oauth"} and not update_data.get("api_key"):
|
||||
raise InvalidRequestException(
|
||||
"从 Vertex AI 切换到 API Key 认证模式时,必须提供新的 API Key"
|
||||
"切换到 API Key 认证模式时,必须提供新的 API Key"
|
||||
)
|
||||
# 切换回 API Key:清理 Service Account 配置
|
||||
# 切换回 API Key:清理非本模式配置
|
||||
update_data["auth_config"] = None
|
||||
elif target_auth_type == "vertex_ai":
|
||||
if current_auth_type != "vertex_ai" and not update_data.get("auth_config"):
|
||||
@@ -255,6 +255,12 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
# Vertex AI 不使用 api_key:写入占位符(若未提供 api_key)
|
||||
if "api_key" not in update_data:
|
||||
update_data["api_key"] = "__placeholder__"
|
||||
elif target_auth_type == "oauth":
|
||||
# OAuth 的 token 不允许在 key 更新接口里手工写入
|
||||
if update_data.get("api_key"):
|
||||
raise InvalidRequestException("OAuth 认证模式下不允许直接填写 api_key")
|
||||
if "api_key" not in update_data:
|
||||
update_data["api_key"] = "__placeholder__"
|
||||
|
||||
# 加密 api_key(非 None 时)
|
||||
if "api_key" in update_data and update_data["api_key"] is not None:
|
||||
@@ -604,6 +610,8 @@ def _build_key_response(
|
||||
if auth_type == "vertex_ai":
|
||||
# Vertex AI 使用 Service Account,不显示占位符
|
||||
masked_key = "[Service Account]"
|
||||
elif auth_type == "oauth":
|
||||
masked_key = "[OAuth Token]"
|
||||
else:
|
||||
try:
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
@@ -726,6 +734,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||
elif auth_type == "vertex_ai":
|
||||
if not self.key_data.auth_config:
|
||||
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
|
||||
elif auth_type == "oauth":
|
||||
# OAuth key 的 token 通过 provider-oauth 授权流程写入(此处不允许手填)
|
||||
if self.key_data.api_key:
|
||||
raise InvalidRequestException("OAuth 认证模式下不允许直接填写 api_key")
|
||||
|
||||
# 允许同一个 API Key 在同一 Provider 下添加多次
|
||||
# 用户可以为不同的 API 格式创建独立的配置记录,便于分开管理
|
||||
@@ -736,6 +748,9 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||
if self.key_data.api_key
|
||||
else crypto_service.encrypt("__placeholder__") # 占位符,保持 NOT NULL 约束
|
||||
)
|
||||
# OAuth 类型 key 初始写入占位符(token 由 provider-oauth 流程写入)
|
||||
if auth_type == "oauth":
|
||||
encrypted_key = crypto_service.encrypt("__placeholder__")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 加密 auth_config(包含敏感的 Service Account 凭证)
|
||||
@@ -787,9 +802,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||
db.commit()
|
||||
db.refresh(new_key)
|
||||
|
||||
key_tail = (self.key_data.api_key or "")[-4:]
|
||||
logger.info(
|
||||
f"[OK] 添加 Key: Provider={self.provider_id}, "
|
||||
f"Formats={self.key_data.api_formats}, Key=***{self.key_data.api_key[-4:]}, ID={new_key.id}"
|
||||
f"Formats={self.key_data.api_formats}, Key=***{key_tail}, ID={new_key.id}"
|
||||
)
|
||||
|
||||
# 如果开启了 auto_fetch_models,同步执行模型获取
|
||||
|
||||
@@ -281,6 +281,11 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
if not provider:
|
||||
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||
|
||||
# 固定类型 Provider:禁止通过该接口新增 Endpoints(端点由模板自动创建并锁定)
|
||||
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
|
||||
if provider_type != "custom":
|
||||
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
|
||||
|
||||
if self.endpoint_data.provider_id != self.provider_id:
|
||||
raise InvalidRequestException("provider_id 不匹配")
|
||||
|
||||
@@ -414,6 +419,16 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
|
||||
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 固定类型 Provider 的 endpoint:锁定 base_url/custom_path(前端禁用仅是 UX,后端必须强校验)
|
||||
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||
if provider:
|
||||
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
|
||||
if provider_type != "custom":
|
||||
if "base_url" in update_data or "custom_path" in update_data:
|
||||
raise InvalidRequestException(
|
||||
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
|
||||
)
|
||||
|
||||
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
|
||||
if "proxy" in update_data:
|
||||
if update_data["proxy"] is not None:
|
||||
|
||||
512
src/api/admin/provider_oauth.py
Normal file
512
src/api/admin/provider_oauth.py
Normal file
@@ -0,0 +1,512 @@
|
||||
"""管理员 Provider OAuth 管理 API。
|
||||
|
||||
用于固定类型 Provider 的 OAuth2 授权:
|
||||
- start: 生成授权 URL(PKCE/state)
|
||||
- complete: 粘贴 callback_url 完成换 token
|
||||
- refresh: 手动强制刷新 token
|
||||
|
||||
注意:
|
||||
- 该模块是“上游 Provider OAuth(用于反代调用)”,不是用户登录/绑定 OAuth。
|
||||
- 不得在日志或响应中返回 access_token/refresh_token/client_secret。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey
|
||||
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
|
||||
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Redis state storage
|
||||
# ==============================================================================
|
||||
|
||||
_PROVIDER_OAUTH_STATE_TTL_SECONDS = 600
|
||||
_PROVIDER_OAUTH_STATE_PREFIX = "provider_oauth_state:"
|
||||
|
||||
|
||||
_CONSUME_STATE_SCRIPT = r"""
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return value
|
||||
"""
|
||||
|
||||
|
||||
def _state_key(nonce: str) -> str:
|
||||
return f"{_PROVIDER_OAUTH_STATE_PREFIX}{nonce}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderOAuthStateData:
|
||||
nonce: str
|
||||
key_id: str
|
||||
provider_type: str
|
||||
pkce_verifier: str | None
|
||||
created_at: int
|
||||
|
||||
|
||||
async def _create_state(
|
||||
redis: Redis,
|
||||
*,
|
||||
key_id: str,
|
||||
provider_type: str,
|
||||
pkce_verifier: str | None,
|
||||
) -> str:
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
data = {
|
||||
"nonce": nonce,
|
||||
"key_id": key_id,
|
||||
"provider_type": provider_type,
|
||||
"pkce_verifier": pkce_verifier,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
await redis.setex(_state_key(nonce), _PROVIDER_OAUTH_STATE_TTL_SECONDS, json.dumps(data))
|
||||
return nonce
|
||||
|
||||
|
||||
async def _consume_state(redis: Redis, nonce: str) -> ProviderOAuthStateData | None:
|
||||
if not nonce:
|
||||
return None
|
||||
key = _state_key(nonce)
|
||||
raw = await redis.eval(_CONSUME_STATE_SCRIPT, 1, key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
return ProviderOAuthStateData(
|
||||
nonce=str(parsed.get("nonce") or ""),
|
||||
key_id=str(parsed.get("key_id") or ""),
|
||||
provider_type=str(parsed.get("provider_type") or ""),
|
||||
pkce_verifier=parsed.get("pkce_verifier"),
|
||||
created_at=int(parsed.get("created_at") or 0),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Requests / responses
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class StartOAuthResponse(BaseModel):
|
||||
authorization_url: str
|
||||
redirect_uri: str
|
||||
provider_type: str
|
||||
instructions: str
|
||||
|
||||
|
||||
class CompleteOAuthRequest(BaseModel):
|
||||
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
|
||||
|
||||
|
||||
class CompleteOAuthResponse(BaseModel):
|
||||
provider_type: str
|
||||
expires_at: int | None = None
|
||||
has_refresh_token: bool = False
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Helpers
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def _require_fixed_provider(provider: Provider) -> str:
|
||||
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
|
||||
if provider_type == "custom":
|
||||
raise InvalidRequestException("该 Provider 不是固定类型,无法使用 provider-oauth")
|
||||
return provider_type
|
||||
|
||||
|
||||
def _pkce_s256(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _parse_callback_params(callback_url: str) -> dict[str, str]:
|
||||
parsed = urlparse(callback_url.strip())
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
fragment = dict(parse_qsl((parsed.fragment or "").lstrip("#"), keep_blank_values=True))
|
||||
merged = {**query, **fragment}
|
||||
|
||||
# Claude 参考实现里:code 参数可能包含 "<code>#<state>" 的拼接形式
|
||||
code = merged.get("code")
|
||||
if code and "#" in code:
|
||||
code_part, state_part = code.split("#", 1)
|
||||
merged["code"] = code_part
|
||||
if "state" not in merged and state_part:
|
||||
merged["state"] = state_part
|
||||
|
||||
return {str(k): str(v) for k, v in merged.items()}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Routes
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@router.get("/supported-types")
|
||||
async def supported_types() -> list[dict[str, Any]]:
|
||||
# 不返回 client_secret
|
||||
result: list[dict[str, Any]] = []
|
||||
for provider_type, template in FIXED_PROVIDERS.items():
|
||||
result.append(
|
||||
{
|
||||
"provider_type": str(provider_type.value) if hasattr(provider_type, "value") else str(provider_type),
|
||||
"display_name": template.display_name,
|
||||
"scopes": list(template.oauth.scopes),
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"authorize_url": template.oauth.authorize_url,
|
||||
"token_url": template.oauth.token_url,
|
||||
"use_pkce": bool(template.oauth.use_pkce),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/start", response_model=StartOAuthResponse)
|
||||
async def start_oauth(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> StartOAuthResponse:
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException("Key 不存在", "key")
|
||||
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
|
||||
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
|
||||
|
||||
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if not template:
|
||||
raise InvalidRequestException("不支持的 provider_type")
|
||||
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
pkce_verifier: str | None = None
|
||||
code_challenge: str | None = None
|
||||
|
||||
if template.oauth.use_pkce:
|
||||
pkce_verifier = secrets.token_urlsafe(32)
|
||||
code_challenge = _pkce_s256(pkce_verifier)
|
||||
|
||||
state = await _create_state(
|
||||
redis,
|
||||
key_id=key_id,
|
||||
provider_type=provider_type,
|
||||
pkce_verifier=pkce_verifier,
|
||||
)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"client_id": template.oauth.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"scope": " ".join(template.oauth.scopes),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
# Codex 参考实现额外参数
|
||||
if provider_type == ProviderType.CODEX.value:
|
||||
params.update(
|
||||
{
|
||||
"prompt": "login",
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
}
|
||||
)
|
||||
|
||||
if template.oauth.use_pkce and code_challenge:
|
||||
params["code_challenge"] = code_challenge
|
||||
params["code_challenge_method"] = "S256"
|
||||
|
||||
authorization_url = f"{template.oauth.authorize_url}?{urlencode(params)}"
|
||||
|
||||
return StartOAuthResponse(
|
||||
authorization_url=authorization_url,
|
||||
redirect_uri=template.oauth.redirect_uri,
|
||||
provider_type=provider_type,
|
||||
instructions=(
|
||||
"1) 打开 authorization_url 完成授权\n"
|
||||
"2) 授权后会跳转到 redirect_uri(localhost)\n"
|
||||
"3) 复制浏览器地址栏完整 URL,调用 complete 接口粘贴 callback_url"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/complete", response_model=CompleteOAuthResponse)
|
||||
async def complete_oauth(
|
||||
key_id: str,
|
||||
payload: CompleteOAuthRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CompleteOAuthResponse:
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
params = _parse_callback_params(payload.callback_url)
|
||||
code = params.get("code")
|
||||
state = params.get("state")
|
||||
if not code or not state:
|
||||
raise InvalidRequestException("callback_url 缺少 code/state")
|
||||
|
||||
state_data = await _consume_state(redis, state)
|
||||
if not state_data or state_data.key_id != key_id:
|
||||
raise InvalidRequestException("state 无效或已过期")
|
||||
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException("Key 不存在", "key")
|
||||
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
|
||||
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
|
||||
|
||||
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if not template:
|
||||
raise InvalidRequestException("不支持的 provider_type")
|
||||
|
||||
# exchange token
|
||||
token_url = template.oauth.token_url
|
||||
|
||||
# Claude token endpoint 是 JSON;Codex/Google 是 form。这里先做最小实现:按 URL 判断。
|
||||
is_json = "anthropic.com" in token_url
|
||||
|
||||
if is_json:
|
||||
body: dict[str, Any] = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": template.oauth.client_id,
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"code": code,
|
||||
"state": state,
|
||||
}
|
||||
if state_data.pkce_verifier:
|
||||
body["code_verifier"] = state_data.pkce_verifier
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
data = None
|
||||
json_body = body
|
||||
else:
|
||||
form: dict[str, str] = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": template.oauth.client_id,
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"code": code,
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
if state_data.pkce_verifier:
|
||||
form["code_verifier"] = state_data.pkce_verifier
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
proxy_config = getattr(provider, "proxy", None)
|
||||
|
||||
resp = await post_oauth_token(
|
||||
provider_type=provider_type,
|
||||
token_url=token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise InvalidRequestException("token exchange 失败")
|
||||
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
refresh_token = str(token.get("refresh_token") or "")
|
||||
expires_in = token.get("expires_in")
|
||||
expires_at: int | None = None
|
||||
try:
|
||||
if expires_in is not None:
|
||||
expires_at = int(time.time()) + int(expires_in)
|
||||
except Exception:
|
||||
expires_at = None
|
||||
|
||||
if not access_token:
|
||||
raise InvalidRequestException("token exchange 返回缺少 access_token")
|
||||
|
||||
# store
|
||||
key.api_key = crypto_service.encrypt(access_token)
|
||||
auth_config: dict[str, Any] = {
|
||||
"provider_type": provider_type,
|
||||
"token_type": token.get("token_type"),
|
||||
"refresh_token": refresh_token or None,
|
||||
"expires_at": expires_at,
|
||||
"scope": token.get("scope"),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
auth_config = await enrich_auth_config(
|
||||
provider_type=provider_type,
|
||||
auth_config=auth_config,
|
||||
token_response=token,
|
||||
access_token=access_token,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
|
||||
db.commit()
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
provider_type=provider_type,
|
||||
expires_at=expires_at,
|
||||
has_refresh_token=bool(refresh_token),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/refresh", response_model=CompleteOAuthResponse)
|
||||
async def refresh_oauth(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CompleteOAuthResponse:
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException("Key 不存在", "key")
|
||||
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
|
||||
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
|
||||
|
||||
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if not template:
|
||||
raise InvalidRequestException("不支持的 provider_type")
|
||||
|
||||
encrypted_auth_config = getattr(key, "auth_config", None)
|
||||
if not encrypted_auth_config:
|
||||
raise InvalidRequestException("缺少 auth_config,无法 refresh")
|
||||
|
||||
decrypted = crypto_service.decrypt(encrypted_auth_config)
|
||||
parsed = json.loads(decrypted)
|
||||
refresh_token = str(parsed.get("refresh_token") or "")
|
||||
if not refresh_token:
|
||||
raise InvalidRequestException("缺少 refresh_token,需要重新授权")
|
||||
|
||||
token_url = template.oauth.token_url
|
||||
is_json = "anthropic.com" in token_url
|
||||
|
||||
if is_json:
|
||||
body: dict[str, Any] = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
data = None
|
||||
json_body = body
|
||||
else:
|
||||
form: dict[str, str] = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
proxy_config = getattr(provider, "proxy", None)
|
||||
|
||||
resp = await post_oauth_token(
|
||||
provider_type=provider_type,
|
||||
token_url=token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise InvalidRequestException("token refresh 失败")
|
||||
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
new_refresh_token = str(token.get("refresh_token") or "")
|
||||
expires_in = token.get("expires_in")
|
||||
expires_at: int | None = None
|
||||
try:
|
||||
if expires_in is not None:
|
||||
expires_at = int(time.time()) + int(expires_in)
|
||||
except Exception:
|
||||
expires_at = None
|
||||
|
||||
if not access_token:
|
||||
raise InvalidRequestException("token refresh 返回缺少 access_token")
|
||||
|
||||
# store
|
||||
key.api_key = crypto_service.encrypt(access_token)
|
||||
parsed["token_type"] = token.get("token_type")
|
||||
if new_refresh_token:
|
||||
parsed["refresh_token"] = new_refresh_token
|
||||
parsed["expires_at"] = expires_at
|
||||
parsed["scope"] = token.get("scope")
|
||||
parsed["updated_at"] = int(time.time())
|
||||
|
||||
parsed = await enrich_auth_config(
|
||||
provider_type=provider_type,
|
||||
auth_config=parsed,
|
||||
token_response=token,
|
||||
access_token=access_token,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(parsed))
|
||||
db.commit()
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
provider_type=provider_type,
|
||||
expires_at=expires_at,
|
||||
has_refresh_token=bool(parsed.get("refresh_token")),
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -20,10 +21,13 @@ from src.core.logger import logger
|
||||
from src.core.model_permissions import match_model_with_pattern, parse_allowed_models_to_list
|
||||
from src.database import get_db
|
||||
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest
|
||||
from src.models.database import GlobalModel, Provider, ProviderAPIKey
|
||||
from src.models.database import GlobalModel, Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.cache.model_cache import ModelCacheService
|
||||
from src.services.cache.provider_cache import ProviderCacheService
|
||||
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
|
||||
router = APIRouter(tags=["Provider CRUD"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
@@ -290,6 +294,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
||||
# 创建 Provider 对象
|
||||
provider = Provider(
|
||||
name=validated_data.name,
|
||||
provider_type=validated_data.provider_type or "custom",
|
||||
description=validated_data.description,
|
||||
website=validated_data.website,
|
||||
billing_type=billing_type,
|
||||
@@ -309,6 +314,37 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
db.add(provider)
|
||||
db.flush() # flush 获取 ID,但不提交,保持在同一事务中
|
||||
|
||||
# 固定类型 Provider:自动创建并锁定预置 Endpoints(同一事务)
|
||||
provider_type = (provider.provider_type or "custom").strip()
|
||||
if provider_type != "custom":
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if template:
|
||||
now = datetime.now(timezone.utc)
|
||||
for sig in template.endpoint_signatures:
|
||||
endpoint = ProviderEndpoint(
|
||||
id=str(uuid.uuid4()),
|
||||
provider_id=provider.id,
|
||||
api_format=sig,
|
||||
api_family=sig.split(":", 1)[0],
|
||||
endpoint_kind=sig.split(":", 1)[1],
|
||||
base_url=template.api_base_url,
|
||||
custom_path=None,
|
||||
header_rules=None,
|
||||
max_retries=provider.max_retries or 2,
|
||||
is_active=True,
|
||||
config=None,
|
||||
proxy=None,
|
||||
format_acceptance_config=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(endpoint)
|
||||
|
||||
db.commit()
|
||||
db.refresh(provider)
|
||||
|
||||
@@ -369,6 +405,8 @@ class AdminUpdateProviderAdapter(AdminApiAdapter):
|
||||
if field == "billing_type" and value is not None:
|
||||
# billing_type 需要转换为枚举
|
||||
setattr(provider, field, ProviderBillingType(value))
|
||||
elif field == "provider_type" and value is not None:
|
||||
setattr(provider, field, value)
|
||||
elif field == "proxy" and value is not None:
|
||||
# proxy 需要转换为 dict(如果是 Pydantic 模型)
|
||||
setattr(
|
||||
|
||||
@@ -305,6 +305,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
|
||||
return ProviderWithEndpointsSummary(
|
||||
id=provider.id,
|
||||
name=provider.name,
|
||||
provider_type=getattr(provider, "provider_type", None),
|
||||
description=provider.description,
|
||||
website=provider.website,
|
||||
provider_priority=provider.provider_priority,
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -25,6 +28,11 @@ from src.core.api_format import (
|
||||
make_signature_key,
|
||||
)
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
||||
@@ -432,6 +440,154 @@ async def get_provider_auth(
|
||||
|
||||
auth_type = getattr(key, "auth_type", "api_key")
|
||||
|
||||
if auth_type == "oauth":
|
||||
# OAuth token 保存在 key.api_key(加密),refresh_token/expires_at 等在 auth_config(加密 JSON)中。
|
||||
# 在请求前做一次懒刷新:接近过期时刷新 access_token,并用 Redis lock 避免并发风暴。
|
||||
encrypted_auth_config = getattr(key, "auth_config", None)
|
||||
if encrypted_auth_config:
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
|
||||
token_meta = json.loads(decrypted_config)
|
||||
except Exception:
|
||||
token_meta = {}
|
||||
else:
|
||||
token_meta = {}
|
||||
|
||||
expires_at = token_meta.get("expires_at")
|
||||
refresh_token = token_meta.get("refresh_token")
|
||||
provider_type = str(token_meta.get("provider_type") or "")
|
||||
|
||||
# 120s skew
|
||||
should_refresh = False
|
||||
try:
|
||||
if expires_at is not None:
|
||||
should_refresh = int(time.time()) >= int(expires_at) - 120
|
||||
except Exception:
|
||||
should_refresh = False
|
||||
|
||||
if should_refresh and refresh_token and provider_type:
|
||||
try:
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if template:
|
||||
redis = await get_redis_client(require_redis=False)
|
||||
lock_key = f"provider_oauth_refresh_lock:{key.id}"
|
||||
got_lock = False
|
||||
if redis is not None:
|
||||
try:
|
||||
got_lock = bool(await redis.set(lock_key, "1", ex=30, nx=True))
|
||||
except Exception:
|
||||
got_lock = False
|
||||
|
||||
if got_lock or redis is None:
|
||||
try:
|
||||
token_url = template.oauth.token_url
|
||||
is_json = "anthropic.com" in token_url
|
||||
|
||||
if is_json:
|
||||
body: dict[str, Any] = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": str(refresh_token),
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = None
|
||||
json_body = body
|
||||
else:
|
||||
form: dict[str, str] = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": str(refresh_token),
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
proxy_config = None
|
||||
try:
|
||||
provider = getattr(key, "provider", None)
|
||||
proxy_config = getattr(provider, "proxy", None)
|
||||
except Exception:
|
||||
proxy_config = None
|
||||
|
||||
resp = await post_oauth_token(
|
||||
provider_type=provider_type,
|
||||
token_url=token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
new_refresh_token = str(token.get("refresh_token") or "")
|
||||
expires_in = token.get("expires_in")
|
||||
new_expires_at: int | None = None
|
||||
try:
|
||||
if expires_in is not None:
|
||||
new_expires_at = int(time.time()) + int(expires_in)
|
||||
except Exception:
|
||||
new_expires_at = None
|
||||
|
||||
if access_token:
|
||||
token_meta["token_type"] = token.get("token_type")
|
||||
if new_refresh_token:
|
||||
token_meta["refresh_token"] = new_refresh_token
|
||||
token_meta["expires_at"] = new_expires_at
|
||||
token_meta["scope"] = token.get("scope")
|
||||
token_meta["updated_at"] = int(time.time())
|
||||
|
||||
token_meta = await enrich_auth_config(
|
||||
provider_type=provider_type,
|
||||
auth_config=token_meta,
|
||||
token_response=token,
|
||||
access_token=access_token,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
key.api_key = crypto_service.encrypt(access_token)
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(token_meta))
|
||||
|
||||
# 持久化:key 实体来自 DB session 时,尝试直接提交更新。
|
||||
sess = object_session(key)
|
||||
if sess is not None:
|
||||
sess.add(key)
|
||||
sess.commit()
|
||||
else:
|
||||
logger.warning(
|
||||
"[OAUTH_REFRESH] key {} 刷新成功但无法持久化(无绑定 session),"
|
||||
"下次请求将重新刷新",
|
||||
key.id,
|
||||
)
|
||||
finally:
|
||||
if got_lock and redis is not None:
|
||||
try:
|
||||
await redis.delete(lock_key)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# 刷新失败不阻断请求;后续由上游返回 401 再触发管理端处理
|
||||
pass
|
||||
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
return ProviderAuthInfo(auth_header="Authorization", auth_value=f"Bearer {decrypted_key}")
|
||||
|
||||
if auth_type == "vertex_ai":
|
||||
from src.core.vertex_auth import VertexAuthError, VertexAuthService
|
||||
|
||||
|
||||
255
src/core/provider_oauth_utils.py
Normal file
255
src/core/provider_oauth_utils.py
Normal file
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from src.clients.http_client import HTTPClientPool, build_proxy_url
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
|
||||
|
||||
|
||||
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
|
||||
if not proxy_config:
|
||||
return None
|
||||
try:
|
||||
if not proxy_config.get("enabled", True):
|
||||
return None
|
||||
return build_proxy_url(proxy_config)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _httpx_post(
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str] | None,
|
||||
data: Any,
|
||||
json_body: Any,
|
||||
proxy_config: dict[str, Any] | None,
|
||||
timeout_seconds: float,
|
||||
) -> httpx.Response:
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
return await client.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json=json_body,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _tls_client_post_sync(
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str] | None,
|
||||
data: Any,
|
||||
json_body: Any,
|
||||
proxy_url: str | None,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[int, dict[str, str], str]:
|
||||
# tls-client is optional at runtime; import only when needed.
|
||||
import tls_client # type: ignore
|
||||
|
||||
session = tls_client.Session(
|
||||
client_identifier="firefox_120",
|
||||
random_tls_extension_order=True,
|
||||
)
|
||||
|
||||
if proxy_url:
|
||||
session.proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
# tls-client uses a requests-like API.
|
||||
resp = session.post(
|
||||
url,
|
||||
headers=headers or {},
|
||||
data=data,
|
||||
json=json_body,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
# Normalize output
|
||||
status_code = int(getattr(resp, "status_code", 0))
|
||||
text = str(getattr(resp, "text", ""))
|
||||
resp_headers = dict(getattr(resp, "headers", {}) or {})
|
||||
return status_code, resp_headers, text
|
||||
|
||||
|
||||
async def post_oauth_token(
|
||||
*,
|
||||
provider_type: str,
|
||||
token_url: str,
|
||||
headers: dict[str, str] | None,
|
||||
data: Any = None,
|
||||
json_body: Any = None,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> httpx.Response:
|
||||
"""POST to token endpoint.
|
||||
|
||||
Claude Code + Anthropic token URL will try tls-client (Firefox TLS fingerprint) first.
|
||||
If tls-client is unavailable or fails, fall back to httpx.
|
||||
|
||||
IMPORTANT: Never log secrets (tokens, secrets). This function only logs generic errors.
|
||||
"""
|
||||
|
||||
if provider_type == "claude_code" and token_url == _ANTHROPIC_TOKEN_URL:
|
||||
proxy_url = _coerce_proxy_url(proxy_config)
|
||||
try:
|
||||
status_code, resp_headers, text = await asyncio.to_thread(
|
||||
_tls_client_post_sync,
|
||||
token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_url=proxy_url,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
headers=resp_headers,
|
||||
content=text.encode("utf-8", errors="replace"),
|
||||
request=httpx.Request("POST", token_url),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Claude OAuth token request via tls-client failed; fallback to httpx. err={!r}",
|
||||
e,
|
||||
)
|
||||
|
||||
return await _httpx_post(
|
||||
token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
|
||||
"""Parse Codex id_token WITHOUT signature verification.
|
||||
|
||||
Extract:
|
||||
- email: claim `email`
|
||||
- account_id: claim `https://api.openai.com/auth`.`chatgpt_account_id`
|
||||
|
||||
Return (email, account_id). On any failure returns (None, None).
|
||||
"""
|
||||
|
||||
if not id_token:
|
||||
return (None, None)
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
options={
|
||||
"verify_signature": False,
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
email = claims.get("email")
|
||||
auth_info = claims.get("https://api.openai.com/auth") or {}
|
||||
account_id = None
|
||||
if isinstance(auth_info, dict):
|
||||
account_id = auth_info.get("chatgpt_account_id")
|
||||
return (
|
||||
str(email) if isinstance(email, str) and email else None,
|
||||
str(account_id) if isinstance(account_id, str) and account_id else None,
|
||||
)
|
||||
except Exception:
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def fetch_google_email(
|
||||
access_token: str,
|
||||
*,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> str | None:
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
try:
|
||||
resp = await client.get(
|
||||
_GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
return None
|
||||
data = resp.json()
|
||||
email = data.get("email")
|
||||
if isinstance(email, str) and email:
|
||||
return email
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
|
||||
# CLIProxyAPI expects: { account: { email_address: ... } }
|
||||
try:
|
||||
account = token.get("account")
|
||||
if isinstance(account, dict):
|
||||
email = account.get("email_address")
|
||||
if isinstance(email, str) and email:
|
||||
return email
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def enrich_auth_config(
|
||||
*,
|
||||
provider_type: str,
|
||||
auth_config: dict[str, Any],
|
||||
token_response: dict[str, Any],
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enrich auth_config with non-secret metadata (email/account_id).
|
||||
|
||||
- Claude Code: email from token response (if present)
|
||||
- Codex: parse id_token -> email/account_id
|
||||
- Gemini/Antigravity: call Google userinfo -> email
|
||||
|
||||
id_token is not persisted.
|
||||
"""
|
||||
|
||||
# Claude
|
||||
if provider_type == "claude_code":
|
||||
email = extract_claude_email_from_token_response(token_response)
|
||||
if email:
|
||||
auth_config["email"] = email
|
||||
return auth_config
|
||||
|
||||
# Codex
|
||||
if provider_type == "codex":
|
||||
id_token = token_response.get("id_token")
|
||||
email, account_id = parse_codex_id_token(str(id_token) if id_token else None)
|
||||
if email:
|
||||
auth_config["email"] = email
|
||||
if account_id:
|
||||
auth_config["account_id"] = account_id
|
||||
return auth_config
|
||||
|
||||
# Gemini family (gemini_cli / antigravity)
|
||||
if provider_type in {"gemini_cli", "antigravity"}:
|
||||
# Only fetch if missing to reduce overhead
|
||||
if not auth_config.get("email"):
|
||||
email = await fetch_google_email(
|
||||
access_token,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
if email:
|
||||
auth_config["email"] = email
|
||||
return auth_config
|
||||
|
||||
return auth_config
|
||||
5
src/core/provider_templates/__init__.py
Normal file
5
src/core/provider_templates/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""固定 Provider 模板与 OAuth 常量。
|
||||
|
||||
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
|
||||
敏感信息(如 client_secret / refresh_token / access_token)不得出现在日志或 API 响应中。
|
||||
"""
|
||||
130
src/core/provider_templates/fixed_providers.py
Normal file
130
src/core/provider_templates/fixed_providers.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""固定 Provider 的模板定义。
|
||||
|
||||
该文件用于集中管理:
|
||||
- 固定 Provider 的上游 API base_url / 固定路径策略(通常通过 EndpointDefinition.default_path + 锁定 custom_path=None)
|
||||
- 固定 Provider 的 OAuth2 客户端常量(authorize/token/client_id/client_secret/scopes/redirect_uri)
|
||||
|
||||
注意:该文件会直接包含从参考项目 CLIProxyAPI 复制的 OAuth client_id/client_secret(敏感)。
|
||||
务必确保:
|
||||
- 不把 client_secret/refresh_token/access_token 输出到日志
|
||||
- 不通过 API 响应把敏感信息返回给前端
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FixedProviderOAuth:
|
||||
authorize_url: str
|
||||
token_url: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
scopes: list[str]
|
||||
redirect_uri: str
|
||||
use_pkce: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FixedProviderTemplate:
|
||||
provider_type: ProviderType
|
||||
display_name: str
|
||||
|
||||
# 上游 API(ProviderEndpoint.base_url 应锁定为该值;custom_path 通常保持 None 使用 default_path)
|
||||
api_base_url: str
|
||||
|
||||
# 该 Provider 默认创建哪些 endpoint signature
|
||||
endpoint_signatures: list[str]
|
||||
|
||||
# OAuth2 配置(用于生成授权 URL / 换 token / refresh)
|
||||
oauth: FixedProviderOAuth
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Fixed templates
|
||||
# ------------------------------
|
||||
# 说明:client_id/client_secret 从 CLIProxyAPI/internal/auth 复制。
|
||||
# 该文件包含敏感信息,务必避免输出到日志或 API 响应。
|
||||
|
||||
FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
|
||||
ProviderType.CLAUDE_CODE: FixedProviderTemplate(
|
||||
provider_type=ProviderType.CLAUDE_CODE,
|
||||
display_name="ClaudeCode",
|
||||
api_base_url="https://api.anthropic.com",
|
||||
endpoint_signatures=["claude:cli"],
|
||||
oauth=FixedProviderOAuth(
|
||||
authorize_url="https://claude.ai/oauth/authorize",
|
||||
token_url="https://console.anthropic.com/v1/oauth/token",
|
||||
client_id="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
client_secret="",
|
||||
scopes=["org:create_api_key", "user:profile", "user:inference"],
|
||||
redirect_uri="http://localhost:54545/callback",
|
||||
use_pkce=True,
|
||||
),
|
||||
),
|
||||
ProviderType.CODEX: FixedProviderTemplate(
|
||||
provider_type=ProviderType.CODEX,
|
||||
display_name="Codex",
|
||||
api_base_url="https://chatgpt.com/backend-api/codex",
|
||||
endpoint_signatures=["openai:cli"],
|
||||
oauth=FixedProviderOAuth(
|
||||
authorize_url="https://auth.openai.com/oauth/authorize",
|
||||
token_url="https://auth.openai.com/oauth/token",
|
||||
client_id="app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
client_secret="",
|
||||
scopes=["openid", "email", "profile", "offline_access"],
|
||||
redirect_uri="http://localhost:1455/auth/callback",
|
||||
use_pkce=True,
|
||||
),
|
||||
),
|
||||
ProviderType.GEMINI_CLI: FixedProviderTemplate(
|
||||
provider_type=ProviderType.GEMINI_CLI,
|
||||
display_name="GeminiCli",
|
||||
api_base_url="https://cloudcode-pa.googleapis.com",
|
||||
endpoint_signatures=["gemini:cli"],
|
||||
oauth=FixedProviderOAuth(
|
||||
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url="https://oauth2.googleapis.com/token",
|
||||
client_id="681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
client_secret="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",
|
||||
scopes=[
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
],
|
||||
redirect_uri="http://localhost:8085/oauth2callback",
|
||||
use_pkce=False,
|
||||
),
|
||||
),
|
||||
ProviderType.ANTIGRAVITY: FixedProviderTemplate(
|
||||
provider_type=ProviderType.ANTIGRAVITY,
|
||||
display_name="Antigravity",
|
||||
api_base_url="https://cloudcode-pa.googleapis.com",
|
||||
endpoint_signatures=["gemini:cli"],
|
||||
oauth=FixedProviderOAuth(
|
||||
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url="https://oauth2.googleapis.com/token",
|
||||
client_id="1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
client_secret="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",
|
||||
scopes=[
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
],
|
||||
redirect_uri="http://localhost:51121/oauth2callback",
|
||||
use_pkce=False,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FixedProviderOAuth",
|
||||
"FixedProviderTemplate",
|
||||
"FIXED_PROVIDERS",
|
||||
]
|
||||
14
src/core/provider_templates/types.py
Normal file
14
src/core/provider_templates/types.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
CUSTOM = "custom"
|
||||
CLAUDE_CODE = "claude_code"
|
||||
CODEX = "codex"
|
||||
GEMINI_CLI = "gemini_cli"
|
||||
ANTIGRAVITY = "antigravity"
|
||||
|
||||
|
||||
__all__ = ["ProviderType"]
|
||||
@@ -55,6 +55,11 @@ class CreateProviderRequest(BaseModel):
|
||||
"""创建 Provider 请求"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="提供商名称(唯一)")
|
||||
provider_type: str | None = Field(
|
||||
default="custom",
|
||||
max_length=20,
|
||||
description="Provider 类型:custom/claude_code/codex/gemini_cli/antigravity",
|
||||
)
|
||||
description: str | None = Field(None, max_length=1000, description="描述")
|
||||
website: str | None = Field(None, max_length=500, description="官网地址")
|
||||
|
||||
@@ -116,6 +121,17 @@ class CreateProviderRequest(BaseModel):
|
||||
)
|
||||
config: dict[str, Any] | None = Field(None, description="其他配置")
|
||||
|
||||
@field_validator("provider_type")
|
||||
@classmethod
|
||||
def validate_provider_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return "custom"
|
||||
v = v.strip()
|
||||
allowed = {"custom", "claude_code", "codex", "gemini_cli", "antigravity"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"无效的 provider_type,有效值为: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
@field_validator("name", "description")
|
||||
@classmethod
|
||||
def sanitize_text(cls, v: str | None) -> str | None:
|
||||
@@ -171,6 +187,11 @@ class UpdateProviderRequest(BaseModel):
|
||||
"""更新 Provider 请求"""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
provider_type: str | None = Field(
|
||||
None,
|
||||
max_length=20,
|
||||
description="Provider 类型:custom/claude_code/codex/gemini_cli/antigravity",
|
||||
)
|
||||
description: str | None = Field(None, max_length=1000)
|
||||
website: str | None = Field(None, max_length=500)
|
||||
billing_type: str | None = None
|
||||
@@ -201,6 +222,9 @@ class UpdateProviderRequest(BaseModel):
|
||||
_validate_billing_type = field_validator("billing_type")(
|
||||
CreateProviderRequest.validate_billing_type.__func__
|
||||
)
|
||||
_validate_provider_type = field_validator("provider_type")(
|
||||
CreateProviderRequest.validate_provider_type.__func__
|
||||
)
|
||||
|
||||
|
||||
class CreateEndpointRequest(BaseModel):
|
||||
|
||||
@@ -638,6 +638,11 @@ class Provider(Base):
|
||||
description = Column(Text, nullable=True) # 提供商描述
|
||||
website = Column(String(500), nullable=True) # 主站网站
|
||||
|
||||
# Provider 类型(用于模板化固定 Provider / 自定义 Provider)
|
||||
# - custom: 自定义
|
||||
# - claude_code / codex / gemini_cli / antigravity: 固定类型
|
||||
provider_type = Column(String(20), default="custom", nullable=False)
|
||||
|
||||
# 计费类型配置
|
||||
billing_type = Column(
|
||||
Enum(
|
||||
@@ -1298,7 +1303,7 @@ class ProviderAPIKey(Base):
|
||||
# API密钥(加密存储)
|
||||
# - auth_type="api_key" 时:存储 API Key 字符串
|
||||
# - auth_type="vertex_ai" 等:可为空,敏感凭证存在 auth_config 中
|
||||
api_key = Column(String(500), nullable=False) # 保持 NOT NULL 兼容历史数据
|
||||
api_key = Column(Text, nullable=False) # 使用 Text 支持加密后的 OAuth token
|
||||
|
||||
# 认证配置(加密存储)
|
||||
# - auth_type="api_key" 时:可为空
|
||||
|
||||
@@ -200,12 +200,16 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
api_key: str = Field(
|
||||
default="", max_length=500, description="API Key(标准认证时必填,将自动加密)"
|
||||
)
|
||||
auth_type: Literal["api_key", "vertex_ai"] = Field(
|
||||
auth_type: Literal["api_key", "vertex_ai", "oauth"] = Field(
|
||||
default="api_key",
|
||||
description="认证类型:api_key(标准 API Key)或 vertex_ai(Vertex AI Service Account)",
|
||||
description="认证类型:api_key(标准 API Key)/ vertex_ai(Vertex AI Service Account)/ oauth(OAuth access_token)",
|
||||
)
|
||||
auth_config: dict[str, Any] | None = Field(
|
||||
default=None, description="认证配置(JSON):vertex_ai 时存储完整 Service Account JSON"
|
||||
default=None,
|
||||
description=(
|
||||
"认证配置(JSON):vertex_ai 时存储完整 Service Account JSON;"
|
||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||
),
|
||||
)
|
||||
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
|
||||
|
||||
@@ -360,12 +364,16 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
max_length=500,
|
||||
description="API Key(标准认证时使用,将自动加密)",
|
||||
)
|
||||
auth_type: Literal["api_key", "vertex_ai"] | None = Field(
|
||||
auth_type: Literal["api_key", "vertex_ai", "oauth"] | None = Field(
|
||||
default=None,
|
||||
description="认证类型:api_key(标准 API Key)或 vertex_ai(Vertex AI Service Account)",
|
||||
description="认证类型:api_key(标准 API Key)/ vertex_ai(Vertex AI Service Account)/ oauth(OAuth access_token)",
|
||||
)
|
||||
auth_config: dict[str, Any] | None = Field(
|
||||
default=None, description="认证配置(JSON):vertex_ai 时存储完整 Service Account JSON"
|
||||
default=None,
|
||||
description=(
|
||||
"认证配置(JSON):vertex_ai 时存储完整 Service Account JSON;"
|
||||
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
|
||||
),
|
||||
)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
|
||||
rate_multipliers: dict[str, float] | None = Field(
|
||||
@@ -690,6 +698,7 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
# Provider 基本信息
|
||||
id: str
|
||||
name: str
|
||||
provider_type: str | None = Field(default=None, description="Provider 类型(custom/claude_code/codex/gemini_cli/antigravity)")
|
||||
description: str | None = None
|
||||
website: str | None = None
|
||||
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
|
||||
|
||||
Reference in New Issue
Block a user