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

@@ -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")

View File

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

View File

@@ -56,7 +56,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
* 获取完整的 API Key用于查看和复制 * 获取完整的 API Key用于查看和复制
*/ */
export interface RevealKeyResult { export interface RevealKeyResult {
auth_type: 'api_key' | 'vertex_ai' auth_type: 'api_key' | 'vertex_ai' | 'oauth'
api_key?: string api_key?: string
auth_config?: string | Record<string, any> auth_config?: string | Record<string, any>
} }
@@ -94,7 +94,7 @@ export async function addProviderKey(
data: { data: {
api_formats: string[] // 支持的 API 格式列表(必填) api_formats: string[] // 支持的 API 格式列表(必填)
api_key: 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 auth_config?: Record<string, any> // 认证配置Vertex AI Service Account JSON
name: string name: string
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
@@ -122,7 +122,7 @@ export async function updateProviderKey(
data: Partial<{ data: Partial<{
api_formats: string[] // 支持的 API 格式列表 api_formats: string[] // 支持的 API 格式列表
api_key: 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 auth_config: Record<string, any> // 认证配置Vertex AI Service Account JSON
name: string name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率 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, providerId: string,
data: Partial<{ data: Partial<{
name: string name: string
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
description: string description: string
website: string website: string
provider_priority: number provider_priority: number
@@ -38,6 +39,7 @@ export async function updateProvider(
proxy: ProxyConfig | null proxy: ProxyConfig | null
cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟) cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)
max_probe_interval_minutes: number max_probe_interval_minutes: number
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
is_active: boolean is_active: boolean
}> }>
): Promise<ProviderWithEndpointsSummary> { ): Promise<ProviderWithEndpointsSummary> {
@@ -48,7 +50,26 @@ export async function updateProvider(
/** /**
* 创建 Provider * 创建 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) const response = await client.post('/api/admin/providers/', data)
return response.data return response.data
} }

View File

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

View File

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

View File

@@ -37,6 +37,7 @@
<Select <Select
v-model="form.auth_type" v-model="form.auth_type"
v-model:open="authTypeSelectOpen" v-model:open="authTypeSelectOpen"
:disabled="authTypeDisabled"
> >
<SelectTrigger :id="authTypeSelectId"> <SelectTrigger :id="authTypeSelectId">
<SelectValue placeholder="选择认证类型" /> <SelectValue placeholder="选择认证类型" />
@@ -48,6 +49,12 @@
<SelectItem value="vertex_ai"> <SelectItem value="vertex_ai">
Vertex AI Vertex AI
</SelectItem> </SelectItem>
<SelectItem
v-if="!isCustomProvider"
value="oauth"
>
OAuth
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -56,8 +63,8 @@
<!-- API 密钥 / Service Account JSON --> <!-- API 密钥 / Service Account JSON -->
<div> <div>
<Label :for="apiKeyInputId"> <Label :for="apiKeyInputId">
{{ form.auth_type === 'vertex_ai' ? 'Service Account JSON' : 'API 密钥' }} {{ form.auth_type === 'vertex_ai' ? 'Service Account JSON' : (form.auth_type === 'oauth' ? 'OAuth Token' : 'API 密钥') }}
{{ editingKey ? '' : '*' }} {{ editingKey ? '' : (form.auth_type === 'oauth' ? '' : '*') }}
</Label> </Label>
<template v-if="form.auth_type === 'vertex_ai'"> <template v-if="form.auth_type === 'vertex_ai'">
<Textarea <Textarea
@@ -75,6 +82,7 @@
</template> </template>
<template v-else> <template v-else>
<Input <Input
v-if="form.auth_type !== 'oauth'"
:id="apiKeyInputId" :id="apiKeyInputId"
v-model="form.api_key" v-model="form.api_key"
:name="apiKeyFieldName" :name="apiKeyFieldName"
@@ -82,6 +90,13 @@
:required="!editingKey" :required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'" :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> </template>
<p <p
v-if="apiKeyError" v-if="apiKeyError"
@@ -95,6 +110,170 @@
> >
留空表示不修改 留空表示不修改
</p> </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> </div>
<!-- 备注 --> <!-- 备注 -->
@@ -314,11 +493,13 @@
</template> </template>
<script setup lang="ts"> <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 { 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 { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form' import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -328,10 +509,14 @@ import {
getAllCapabilities, getAllCapabilities,
API_FORMAT_LABELS, API_FORMAT_LABELS,
sortApiFormats, sortApiFormats,
startProviderOAuth,
completeProviderOAuth,
refreshProviderOAuth,
type EndpointAPIKey, type EndpointAPIKey,
type EndpointAPIKeyUpdate, type EndpointAPIKeyUpdate,
type ProviderEndpoint, type ProviderEndpoint,
type CapabilityDefinition type CapabilityDefinition,
type ProviderType
} from '@/api/endpoints' } from '@/api/endpoints'
const props = defineProps<{ const props = defineProps<{
@@ -339,19 +524,79 @@ const props = defineProps<{
endpoint: ProviderEndpoint | null endpoint: ProviderEndpoint | null
editingKey: EndpointAPIKey | null editingKey: EndpointAPIKey | null
providerId: string | null providerId: string | null
providerType: ProviderType | null
availableApiFormats: string[] // Provider 支持的所有 API 格式 availableApiFormats: string[] // Provider 支持的所有 API 格式
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
saved: [] saved: []
editCreatedKey: [key: EndpointAPIKey]
}>() }>()
const { success, error: showError } = useToast() 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 格式列表 // 排序后的可用 API 格式列表
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats)) 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 // 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => { const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false if (!props.editingKey) return false
@@ -387,6 +632,7 @@ const canSave = computed(() => {
if (!props.editingKey) { if (!props.editingKey) {
if (form.value.auth_type === 'api_key' && !form.value.api_key.trim()) return false 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 if (form.value.auth_type === 'vertex_ai' && !form.value.auth_config_text.trim()) return false
// OAuthtoken 由 provider-oauth 授权流程写入,这里不要求填写
} else { } else {
// 编辑模式下切换认证类型时,必须填写对应字段 // 编辑模式下切换认证类型时,必须填写对应字段
if (switchingToApiKey.value && !form.value.api_key.trim()) return false 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 authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`) const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-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[]>([]) const availableCapabilities = ref<CapabilityDefinition[]>([])
@@ -415,7 +742,7 @@ const availableCapabilities = ref<CapabilityDefinition[]>([])
const form = ref({ const form = ref({
name: '', name: '',
api_key: '', // 标准 API Key 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 文本(用于表单输入) auth_config_text: '', // Service Account JSON 文本(用于表单输入)
api_formats: [] as string[], // 支持的 API 格式列表 api_formats: [] as string[], // 支持的 API 格式列表
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率 rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
@@ -499,11 +826,12 @@ const apiKeyError = computed(() => {
// 重置表单 // 重置表单
function resetForm() { function resetForm() {
resetOAuthState()
formNonce.value = createFieldNonce() formNonce.value = createFieldNonce()
form.value = { form.value = {
name: '', name: '',
api_key: '', api_key: '',
auth_type: 'api_key', auth_type: defaultAuthType.value,
auth_config_text: '', auth_config_text: '',
api_formats: [], // 默认不选中任何格式 api_formats: [], // 默认不选中任何格式
rate_multipliers: {}, rate_multipliers: {},
@@ -531,6 +859,7 @@ function clearForNextAdd() {
// 加载密钥数据(编辑模式) // 加载密钥数据(编辑模式)
function loadKeyData() { function loadKeyData() {
if (!props.editingKey) return if (!props.editingKey) return
resetOAuthState()
formNonce.value = createFieldNonce() formNonce.value = createFieldNonce()
form.value = { form.value = {
name: props.editingKey.name, name: props.editingKey.name,
@@ -565,6 +894,12 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm, resetForm,
}) })
watch(() => form.value.auth_type, (newType, oldType) => {
if (oldType === 'oauth' && newType !== 'oauth') {
resetOAuthState()
}
})
function createFieldNonce(): string { function createFieldNonce(): string {
return Math.random().toString(36).slice(2, 10) 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() { async function handleSave() {
// 必须有 providerId // 必须有 providerId
if (!props.providerId) { if (!props.providerId) {
@@ -631,6 +1027,8 @@ async function handleSave() {
return return
} }
} }
} else if (form.value.auth_type === 'oauth') {
// OAuth不在此处输入 token由 provider-oauth 授权流程写入
} }
// 验证至少选择一个 API 格式 // 验证至少选择一个 API 格式
@@ -697,7 +1095,7 @@ async function handleSave() {
success('密钥已更新', '成功') success('密钥已更新', '成功')
} else { } else {
// 新增模式 // 新增模式
await addProviderKey(props.providerId, { const createdKey = await addProviderKey(props.providerId, {
api_formats: form.value.api_formats, api_formats: form.value.api_formats,
api_key: form.value.auth_type === 'api_key' ? form.value.api_key : '', api_key: form.value.auth_type === 'api_key' ? form.value.api_key : '',
auth_type: form.value.auth_type, auth_type: form.value.auth_type,
@@ -714,6 +1112,15 @@ async function handleSave() {
model_include_patterns: parsePatternText(form.value.model_include_patterns_text), model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_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('密钥已添加', '成功') success('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加 // 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
emit('saved') emit('saved')

View File

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

View File

@@ -27,13 +27,29 @@
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="website">主站链接</Label> <Label>提供商类型</Label>
<Input <Select
id="website" v-model="form.provider_type"
v-model="form.website" v-model:open="providerTypeSelectOpen"
placeholder="https://..." :disabled="isEditMode"
type="url" >
/> <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>
</div> </div>
@@ -45,6 +61,15 @@
placeholder="提供商描述(可选)" placeholder="提供商描述(可选)"
/> />
</div> </div>
<div class="space-y-1.5">
<Label for="website">主站链接</Label>
<Input
id="website"
v-model="form.website"
placeholder="https://example.com可选"
/>
</div>
</div> </div>
<!-- 计费与限流 / 请求配置 --> <!-- 计费与限流 / 请求配置 -->
@@ -297,6 +322,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const loading = ref(false) const loading = ref(false)
const providerTypeSelectOpen = ref(false)
const billingTypeSelectOpen = ref(false) const billingTypeSelectOpen = ref(false)
// 内部状态 // 内部状态
@@ -305,6 +331,7 @@ const internalOpen = computed(() => props.modelValue)
// 表单数据 // 表单数据
const form = ref({ const form = ref({
name: '', name: '',
provider_type: 'custom' as 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity',
description: '', description: '',
website: '', website: '',
// 计费配置 // 计费配置
@@ -335,6 +362,7 @@ const form = ref({
function resetForm() { function resetForm() {
form.value = { form.value = {
name: '', name: '',
provider_type: 'custom',
description: '', description: '',
website: '', website: '',
billing_type: 'pay_as_you_go', billing_type: 'pay_as_you_go',
@@ -367,6 +395,7 @@ function loadProviderData() {
const proxy = props.provider.proxy const proxy = props.provider.proxy
form.value = { form.value = {
name: props.provider.name, name: props.provider.name,
provider_type: props.provider.provider_type || 'custom',
description: props.provider.description || '', description: props.provider.description || '',
website: props.provider.website || '', website: props.provider.website || '',
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go', 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 = { const payload = {
name: form.value.name, name: form.value.name,
provider_type: form.value.provider_type,
description: form.value.description || undefined, description: form.value.description || undefined,
website: form.value.website || undefined, website: form.value.website || undefined,
billing_type: form.value.billing_type, billing_type: form.value.billing_type,

View File

@@ -55,6 +55,9 @@ dev = [
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"httpx>=0.25.0", "httpx>=0.25.0",
] ]
tls = [
"tls-client>=1.0.1", # 可选:用于 Claude OAuth token 请求的 TLS 指纹伪装
]
[project.urls] [project.urls]
Homepage = "https://github.com/fawney19/Aether" Homepage = "https://github.com/fawney19/Aether"

View File

@@ -13,6 +13,7 @@ from .monitoring import router as monitoring_router
from .provider_ops import router as provider_ops_router from .provider_ops import router as provider_ops_router
from .provider_query import router as provider_query_router from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_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 .providers import router as providers_router
from .security import router as security_router from .security import router as security_router
from .stats import router as stats_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(monitoring_router)
router.include_router(endpoints_router) router.include_router(endpoints_router)
router.include_router(provider_strategy_router) router.include_router(provider_strategy_router)
router.include_router(provider_oauth_router)
router.include_router(adaptive_router) router.include_router(adaptive_router)
router.include_router(models_router) router.include_router(models_router)
router.include_router(security_router) router.include_router(security_router)

View File

@@ -241,11 +241,11 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# auth_type 切换校验 + 字段归一化 # auth_type 切换校验 + 字段归一化
if "auth_type" in update_data: if "auth_type" in update_data:
if target_auth_type == "api_key": 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( raise InvalidRequestException(
"从 Vertex AI 切换到 API Key 认证模式时,必须提供新的 API Key" "切换到 API Key 认证模式时,必须提供新的 API Key"
) )
# 切换回 API Key清理 Service Account 配置 # 切换回 API Key清理非本模式配置
update_data["auth_config"] = None update_data["auth_config"] = None
elif target_auth_type == "vertex_ai": elif target_auth_type == "vertex_ai":
if current_auth_type != "vertex_ai" and not update_data.get("auth_config"): 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 # Vertex AI 不使用 api_key写入占位符若未提供 api_key
if "api_key" not in update_data: if "api_key" not in update_data:
update_data["api_key"] = "__placeholder__" 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 时) # 加密 api_key非 None 时)
if "api_key" in update_data and update_data["api_key"] is not 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": if auth_type == "vertex_ai":
# Vertex AI 使用 Service Account不显示占位符 # Vertex AI 使用 Service Account不显示占位符
masked_key = "[Service Account]" masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"
else: else:
try: try:
decrypted_key = crypto_service.decrypt(key.api_key) decrypted_key = crypto_service.decrypt(key.api_key)
@@ -726,6 +734,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
elif auth_type == "vertex_ai": elif auth_type == "vertex_ai":
if not self.key_data.auth_config: if not self.key_data.auth_config:
raise InvalidRequestException("Service Account 认证模式下 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 Key 在同一 Provider 下添加多次
# 用户可以为不同的 API 格式创建独立的配置记录,便于分开管理 # 用户可以为不同的 API 格式创建独立的配置记录,便于分开管理
@@ -736,6 +748,9 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
if self.key_data.api_key if self.key_data.api_key
else crypto_service.encrypt("__placeholder__") # 占位符,保持 NOT NULL 约束 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) now = datetime.now(timezone.utc)
# 加密 auth_config包含敏感的 Service Account 凭证) # 加密 auth_config包含敏感的 Service Account 凭证)
@@ -787,9 +802,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
db.commit() db.commit()
db.refresh(new_key) db.refresh(new_key)
key_tail = (self.key_data.api_key or "")[-4:]
logger.info( logger.info(
f"[OK] 添加 Key: Provider={self.provider_id}, " 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同步执行模型获取 # 如果开启了 auto_fetch_models同步执行模型获取

View File

@@ -281,6 +281,11 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
if not provider: if not provider:
raise NotFoundException(f"Provider {self.provider_id} 不存在") 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: if self.endpoint_data.provider_id != self.provider_id:
raise InvalidRequestException("provider_id 不匹配") raise InvalidRequestException("provider_id 不匹配")
@@ -414,6 +419,16 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
update_data = self.endpoint_data.model_dump(exclude_unset=True) 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 清除代理 # 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data: if "proxy" in update_data:
if update_data["proxy"] is not None: if update_data["proxy"] is not None:

View File

@@ -0,0 +1,512 @@
"""管理员 Provider OAuth 管理 API。
用于固定类型 Provider 的 OAuth2 授权:
- start: 生成授权 URLPKCE/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_urilocalhost\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 是 JSONCodex/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")),
)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any 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.core.model_permissions import match_model_with_pattern, parse_allowed_models_to_list
from src.database import get_db from src.database import get_db
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest 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.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService 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"]) router = APIRouter(tags=["Provider CRUD"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -290,6 +294,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
# 创建 Provider 对象 # 创建 Provider 对象
provider = Provider( provider = Provider(
name=validated_data.name, name=validated_data.name,
provider_type=validated_data.provider_type or "custom",
description=validated_data.description, description=validated_data.description,
website=validated_data.website, website=validated_data.website,
billing_type=billing_type, billing_type=billing_type,
@@ -309,6 +314,37 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
) )
db.add(provider) 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.commit()
db.refresh(provider) db.refresh(provider)
@@ -369,6 +405,8 @@ class AdminUpdateProviderAdapter(AdminApiAdapter):
if field == "billing_type" and value is not None: if field == "billing_type" and value is not None:
# billing_type 需要转换为枚举 # billing_type 需要转换为枚举
setattr(provider, field, ProviderBillingType(value)) 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: elif field == "proxy" and value is not None:
# proxy 需要转换为 dict如果是 Pydantic 模型) # proxy 需要转换为 dict如果是 Pydantic 模型)
setattr( setattr(

View File

@@ -305,6 +305,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
return ProviderWithEndpointsSummary( return ProviderWithEndpointsSummary(
id=provider.id, id=provider.id,
name=provider.name, name=provider.name,
provider_type=getattr(provider, "provider_type", None),
description=provider.description, description=provider.description,
website=provider.website, website=provider.website,
provider_priority=provider.provider_priority, provider_priority=provider.provider_priority,

View File

@@ -14,6 +14,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
import httpx
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -25,6 +28,11 @@ from src.core.api_format import (
make_signature_key, make_signature_key,
) )
from src.core.crypto import crypto_service 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: if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint from src.models.database import ProviderAPIKey, ProviderEndpoint
@@ -432,6 +440,154 @@ async def get_provider_auth(
auth_type = getattr(key, "auth_type", "api_key") 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": if auth_type == "vertex_ai":
from src.core.vertex_auth import VertexAuthError, VertexAuthService from src.core.vertex_auth import VertexAuthError, VertexAuthService

View 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

View File

@@ -0,0 +1,5 @@
"""固定 Provider 模板与 OAuth 常量。
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
敏感信息(如 client_secret / refresh_token / access_token不得出现在日志或 API 响应中。
"""

View 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
# 上游 APIProviderEndpoint.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",
]

View 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"]

View File

@@ -55,6 +55,11 @@ class CreateProviderRequest(BaseModel):
"""创建 Provider 请求""" """创建 Provider 请求"""
name: str = Field(..., min_length=1, max_length=100, description="提供商名称(唯一)") 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="描述") description: str | None = Field(None, max_length=1000, description="描述")
website: str | None = Field(None, max_length=500, 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="其他配置") 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") @field_validator("name", "description")
@classmethod @classmethod
def sanitize_text(cls, v: str | None) -> str | None: def sanitize_text(cls, v: str | None) -> str | None:
@@ -171,6 +187,11 @@ class UpdateProviderRequest(BaseModel):
"""更新 Provider 请求""" """更新 Provider 请求"""
name: str | None = Field(None, min_length=1, max_length=100) 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) description: str | None = Field(None, max_length=1000)
website: str | None = Field(None, max_length=500) website: str | None = Field(None, max_length=500)
billing_type: str | None = None billing_type: str | None = None
@@ -201,6 +222,9 @@ class UpdateProviderRequest(BaseModel):
_validate_billing_type = field_validator("billing_type")( _validate_billing_type = field_validator("billing_type")(
CreateProviderRequest.validate_billing_type.__func__ CreateProviderRequest.validate_billing_type.__func__
) )
_validate_provider_type = field_validator("provider_type")(
CreateProviderRequest.validate_provider_type.__func__
)
class CreateEndpointRequest(BaseModel): class CreateEndpointRequest(BaseModel):

View File

@@ -638,6 +638,11 @@ class Provider(Base):
description = Column(Text, nullable=True) # 提供商描述 description = Column(Text, nullable=True) # 提供商描述
website = Column(String(500), 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( billing_type = Column(
Enum( Enum(
@@ -1298,7 +1303,7 @@ class ProviderAPIKey(Base):
# API密钥加密存储 # API密钥加密存储
# - auth_type="api_key" 时:存储 API Key 字符串 # - auth_type="api_key" 时:存储 API Key 字符串
# - auth_type="vertex_ai" 等:可为空,敏感凭证存在 auth_config 中 # - 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" 时:可为空 # - auth_type="api_key" 时:可为空

View File

@@ -200,12 +200,16 @@ class EndpointAPIKeyCreate(BaseModel):
api_key: str = Field( api_key: str = Field(
default="", max_length=500, description="API Key标准认证时必填将自动加密" 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", default="api_key",
description="认证类型api_key标准 API Key vertex_aiVertex AI Service Account", description="认证类型api_key标准 API Key/ vertex_aiVertex AI Service Account/ oauthOAuth access_token",
) )
auth_config: dict[str, Any] | None = Field( auth_config: dict[str, Any] | None = Field(
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON" default=None,
description=(
"认证配置JSONvertex_ai 时存储完整 Service Account JSON"
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
) )
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)") name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
@@ -360,12 +364,16 @@ class EndpointAPIKeyUpdate(BaseModel):
max_length=500, max_length=500,
description="API Key标准认证时使用将自动加密", description="API Key标准认证时使用将自动加密",
) )
auth_type: Literal["api_key", "vertex_ai"] | None = Field( auth_type: Literal["api_key", "vertex_ai", "oauth"] | None = Field(
default=None, default=None,
description="认证类型api_key标准 API Key vertex_aiVertex AI Service Account", description="认证类型api_key标准 API Key/ vertex_aiVertex AI Service Account/ oauthOAuth access_token",
) )
auth_config: dict[str, Any] | None = Field( auth_config: dict[str, Any] | None = Field(
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON" default=None,
description=(
"认证配置JSONvertex_ai 时存储完整 Service Account JSON"
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
) )
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称") name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
rate_multipliers: dict[str, float] | None = Field( rate_multipliers: dict[str, float] | None = Field(
@@ -690,6 +698,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Provider 基本信息 # Provider 基本信息
id: str id: str
name: str name: str
provider_type: str | None = Field(default=None, description="Provider 类型custom/claude_code/codex/gemini_cli/antigravity")
description: str | None = None description: str | None = None
website: str | None = None website: str | None = None
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)") provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")