mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Merge pull request #29 from fawney19/feature/proxy-support
feat: add HTTP/SOCKS5 proxy support for API endpoints
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
"""add proxy field to provider_endpoints
|
||||||
|
|
||||||
|
Revision ID: f30f9936f6a2
|
||||||
|
Revises: 1cc6942cf06f
|
||||||
|
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f30f9936f6a2'
|
||||||
|
down_revision = '1cc6942cf06f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = 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 get_column_type(table_name: str, column_name: str) -> str:
|
||||||
|
"""获取列的类型"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
for col in inspector.get_columns(table_name):
|
||||||
|
if col['name'] == column_name:
|
||||||
|
return str(col['type']).upper()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||||
|
if not column_exists('provider_endpoints', 'proxy'):
|
||||||
|
# 字段不存在,直接添加 JSONB 类型
|
||||||
|
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||||
|
else:
|
||||||
|
# 字段已存在,检查是否需要转换类型
|
||||||
|
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||||
|
if 'JSONB' not in col_type:
|
||||||
|
# 如果是 JSON 类型,转换为 JSONB
|
||||||
|
op.execute(
|
||||||
|
'ALTER TABLE provider_endpoints '
|
||||||
|
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""移除 proxy 字段"""
|
||||||
|
if column_exists('provider_endpoints', 'proxy'):
|
||||||
|
op.drop_column('provider_endpoints', 'proxy')
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import type { ProviderEndpoint } from './types'
|
import type { ProviderEndpoint, ProxyConfig } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定 Provider 的所有 Endpoints
|
* 获取指定 Provider 的所有 Endpoints
|
||||||
@@ -38,6 +38,7 @@ export async function createEndpoint(
|
|||||||
rate_limit?: number
|
rate_limit?: number
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
|
proxy?: ProxyConfig | null
|
||||||
}
|
}
|
||||||
): Promise<ProviderEndpoint> {
|
): Promise<ProviderEndpoint> {
|
||||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
|
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
|
||||||
@@ -63,6 +64,7 @@ export async function updateEndpoint(
|
|||||||
rate_limit: number
|
rate_limit: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config: Record<string, any>
|
config: Record<string, any>
|
||||||
|
proxy: ProxyConfig | null
|
||||||
}>
|
}>
|
||||||
): Promise<ProviderEndpoint> {
|
): Promise<ProviderEndpoint> {
|
||||||
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
|
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
|||||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理配置类型
|
||||||
|
*/
|
||||||
|
export interface ProxyConfig {
|
||||||
|
url: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProviderEndpoint {
|
export interface ProviderEndpoint {
|
||||||
id: string
|
id: string
|
||||||
provider_id: string
|
provider_id: string
|
||||||
@@ -41,6 +51,7 @@ export interface ProviderEndpoint {
|
|||||||
last_failure_at?: string
|
last_failure_at?: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
|
proxy?: ProxyConfig | null
|
||||||
total_keys: number
|
total_keys: number
|
||||||
active_keys: number
|
active_keys: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit()"
|
||||||
>
|
>
|
||||||
<!-- API 配置 -->
|
<!-- API 配置 -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -132,6 +132,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 代理配置 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
代理配置
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch v-model="proxyEnabled" />
|
||||||
|
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="proxyEnabled"
|
||||||
|
class="space-y-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="proxy_url">代理 URL *</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy_url"
|
||||||
|
v-model="form.proxy_url"
|
||||||
|
placeholder="http://host:port 或 socks5://host:port"
|
||||||
|
required
|
||||||
|
:class="proxyUrlError ? 'border-red-500' : ''"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="proxyUrlError"
|
||||||
|
class="text-xs text-red-500"
|
||||||
|
>
|
||||||
|
{{ proxyUrlError }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
支持 HTTP、HTTPS、SOCKS5 代理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="proxy_user">用户名(可选)</Label>
|
||||||
|
<Input
|
||||||
|
:id="`proxy_user_${formId}`"
|
||||||
|
:name="`proxy_user_${formId}`"
|
||||||
|
v-model="form.proxy_username"
|
||||||
|
placeholder="代理认证用户名"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||||
|
<Input
|
||||||
|
:id="`proxy_pass_${formId}`"
|
||||||
|
:name="`proxy_pass_${formId}`"
|
||||||
|
v-model="form.proxy_password"
|
||||||
|
type="text"
|
||||||
|
:placeholder="passwordPlaceholder"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -145,12 +218,24 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit()"
|
||||||
>
|
>
|
||||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 确认清空凭据对话框 -->
|
||||||
|
<AlertDialog
|
||||||
|
v-model="showClearCredentialsDialog"
|
||||||
|
title="清空代理凭据"
|
||||||
|
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
|
||||||
|
type="warning"
|
||||||
|
confirm-text="清空并保存"
|
||||||
|
cancel-text="返回编辑"
|
||||||
|
@confirm="confirmClearCredentials"
|
||||||
|
@cancel="showClearCredentialsDialog = false"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -165,7 +250,9 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
Switch,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
import { Link, SquarePen } from 'lucide-vue-next'
|
import { Link, SquarePen } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
@@ -194,6 +281,11 @@ const emit = defineEmits<{
|
|||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectOpen = ref(false)
|
const selectOpen = ref(false)
|
||||||
|
const proxyEnabled = ref(false)
|
||||||
|
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
|
||||||
|
|
||||||
|
// 生成随机 ID 防止浏览器自动填充
|
||||||
|
const formId = Math.random().toString(36).substring(2, 10)
|
||||||
|
|
||||||
// 内部状态
|
// 内部状态
|
||||||
const internalOpen = computed(() => props.modelValue)
|
const internalOpen = computed(() => props.modelValue)
|
||||||
@@ -207,7 +299,11 @@ const form = ref({
|
|||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
max_concurrent: undefined as number | undefined,
|
max_concurrent: undefined as number | undefined,
|
||||||
rate_limit: undefined as number | undefined,
|
rate_limit: undefined as number | undefined,
|
||||||
is_active: true
|
is_active: true,
|
||||||
|
// 代理配置
|
||||||
|
proxy_url: '',
|
||||||
|
proxy_username: '',
|
||||||
|
proxy_password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// API 格式列表
|
// API 格式列表
|
||||||
@@ -237,6 +333,53 @@ const defaultPathPlaceholder = computed(() => {
|
|||||||
return `留空使用默认路径:${defaultPath.value}`
|
return `留空使用默认路径:${defaultPath.value}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
|
||||||
|
const hasExistingPassword = computed(() => {
|
||||||
|
if (!props.endpoint?.proxy) return false
|
||||||
|
const proxy = props.endpoint.proxy as { password?: string }
|
||||||
|
return proxy?.password === MASKED_PASSWORD
|
||||||
|
})
|
||||||
|
|
||||||
|
// 密码输入框的 placeholder
|
||||||
|
const passwordPlaceholder = computed(() => {
|
||||||
|
if (hasExistingPassword.value) {
|
||||||
|
return '已保存密码,留空保持不变'
|
||||||
|
}
|
||||||
|
return '代理认证密码'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 代理 URL 验证
|
||||||
|
const proxyUrlError = computed(() => {
|
||||||
|
// 只有启用代理且填写了 URL 时才验证
|
||||||
|
if (!proxyEnabled.value || !form.value.proxy_url) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const url = form.value.proxy_url.trim()
|
||||||
|
|
||||||
|
// 检查禁止的特殊字符
|
||||||
|
if (/[\n\r]/.test(url)) {
|
||||||
|
return '代理 URL 包含非法字符'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证协议(不支持 SOCKS4)
|
||||||
|
if (!/^(http|https|socks5):\/\//i.test(url)) {
|
||||||
|
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (!parsed.host) {
|
||||||
|
return '代理 URL 必须包含有效的 host'
|
||||||
|
}
|
||||||
|
// 禁止 URL 中内嵌认证信息
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '代理 URL 格式无效'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
// 组件挂载时加载API格式
|
// 组件挂载时加载API格式
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadApiFormats()
|
loadApiFormats()
|
||||||
@@ -252,14 +395,23 @@ function resetForm() {
|
|||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
max_concurrent: undefined,
|
max_concurrent: undefined,
|
||||||
rate_limit: undefined,
|
rate_limit: undefined,
|
||||||
is_active: true
|
is_active: true,
|
||||||
|
proxy_url: '',
|
||||||
|
proxy_username: '',
|
||||||
|
proxy_password: '',
|
||||||
}
|
}
|
||||||
|
proxyEnabled.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原始密码占位符(后端返回的脱敏标记)
|
||||||
|
const MASKED_PASSWORD = '***'
|
||||||
|
|
||||||
// 加载端点数据(编辑模式)
|
// 加载端点数据(编辑模式)
|
||||||
function loadEndpointData() {
|
function loadEndpointData() {
|
||||||
if (!props.endpoint) return
|
if (!props.endpoint) return
|
||||||
|
|
||||||
|
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
|
||||||
|
|
||||||
form.value = {
|
form.value = {
|
||||||
api_format: props.endpoint.api_format,
|
api_format: props.endpoint.api_format,
|
||||||
base_url: props.endpoint.base_url,
|
base_url: props.endpoint.base_url,
|
||||||
@@ -268,8 +420,15 @@ function loadEndpointData() {
|
|||||||
max_retries: props.endpoint.max_retries,
|
max_retries: props.endpoint.max_retries,
|
||||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
max_concurrent: props.endpoint.max_concurrent || undefined,
|
||||||
rate_limit: props.endpoint.rate_limit || undefined,
|
rate_limit: props.endpoint.rate_limit || undefined,
|
||||||
is_active: props.endpoint.is_active
|
is_active: props.endpoint.is_active,
|
||||||
|
proxy_url: proxy?.url || '',
|
||||||
|
proxy_username: proxy?.username || '',
|
||||||
|
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
|
||||||
|
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据 enabled 字段或 url 存在判断是否启用代理
|
||||||
|
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 useFormDialog 统一处理对话框逻辑
|
// 使用 useFormDialog 统一处理对话框逻辑
|
||||||
@@ -282,12 +441,47 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
|||||||
resetForm,
|
resetForm,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 构建代理配置
|
||||||
|
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
|
||||||
|
// - 无 URL 时返回 null
|
||||||
|
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
|
||||||
|
if (!form.value.proxy_url) {
|
||||||
|
// 没填 URL,无代理配置
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: form.value.proxy_url,
|
||||||
|
username: form.value.proxy_username || undefined,
|
||||||
|
password: form.value.proxy_password || undefined,
|
||||||
|
enabled: proxyEnabled.value, // 开关状态决定是否启用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (skipCredentialCheck = false) => {
|
||||||
if (!props.provider && !props.endpoint) return
|
if (!props.provider && !props.endpoint) return
|
||||||
|
|
||||||
|
// 只在开关开启且填写了 URL 时验证
|
||||||
|
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
|
||||||
|
showError(proxyUrlError.value, '代理配置错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查:开关开启但没有 URL,却有用户名或密码
|
||||||
|
const hasOrphanedCredentials = proxyEnabled.value
|
||||||
|
&& !form.value.proxy_url
|
||||||
|
&& (form.value.proxy_username || form.value.proxy_password)
|
||||||
|
|
||||||
|
if (hasOrphanedCredentials && !skipCredentialCheck) {
|
||||||
|
// 弹出确认对话框
|
||||||
|
showClearCredentialsDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const proxyConfig = buildProxyConfig()
|
||||||
|
|
||||||
if (isEditMode.value && props.endpoint) {
|
if (isEditMode.value && props.endpoint) {
|
||||||
// 更新端点
|
// 更新端点
|
||||||
await updateEndpoint(props.endpoint.id, {
|
await updateEndpoint(props.endpoint.id, {
|
||||||
@@ -297,7 +491,8 @@ const handleSubmit = async () => {
|
|||||||
max_retries: form.value.max_retries,
|
max_retries: form.value.max_retries,
|
||||||
max_concurrent: form.value.max_concurrent,
|
max_concurrent: form.value.max_concurrent,
|
||||||
rate_limit: form.value.rate_limit,
|
rate_limit: form.value.rate_limit,
|
||||||
is_active: form.value.is_active
|
is_active: form.value.is_active,
|
||||||
|
proxy: proxyConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
success('端点已更新', '保存成功')
|
success('端点已更新', '保存成功')
|
||||||
@@ -313,7 +508,8 @@ const handleSubmit = async () => {
|
|||||||
max_retries: form.value.max_retries,
|
max_retries: form.value.max_retries,
|
||||||
max_concurrent: form.value.max_concurrent,
|
max_concurrent: form.value.max_concurrent,
|
||||||
rate_limit: form.value.rate_limit,
|
rate_limit: form.value.rate_limit,
|
||||||
is_active: form.value.is_active
|
is_active: form.value.is_active,
|
||||||
|
proxy: proxyConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
success('端点创建成功', '成功')
|
success('端点创建成功', '成功')
|
||||||
@@ -329,4 +525,12 @@ const handleSubmit = async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确认清空凭据并继续保存
|
||||||
|
const confirmClearCredentials = () => {
|
||||||
|
form.value.proxy_username = ''
|
||||||
|
form.value.proxy_password = ''
|
||||||
|
showClearCredentialsDialog.value = false
|
||||||
|
handleSubmit(true) // 跳过凭据检查,直接提交
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ ProviderEndpoint CRUD 管理 API
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import and_, func
|
from sqlalchemy import and_, func
|
||||||
@@ -27,6 +27,16 @@ router = APIRouter(tags=["Endpoint Management"])
|
|||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def mask_proxy_password(proxy_config: Optional[dict]) -> Optional[dict]:
|
||||||
|
"""对代理配置中的密码进行脱敏处理"""
|
||||||
|
if not proxy_config:
|
||||||
|
return None
|
||||||
|
masked = dict(proxy_config)
|
||||||
|
if masked.get("password"):
|
||||||
|
masked["password"] = "***"
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/{provider_id}/endpoints", response_model=List[ProviderEndpointResponse])
|
@router.get("/providers/{provider_id}/endpoints", response_model=List[ProviderEndpointResponse])
|
||||||
async def list_provider_endpoints(
|
async def list_provider_endpoints(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
@@ -153,6 +163,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
|||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"total_keys": total_keys_map.get(endpoint.id, 0),
|
"total_keys": total_keys_map.get(endpoint.id, 0),
|
||||||
"active_keys": active_keys_map.get(endpoint.id, 0),
|
"active_keys": active_keys_map.get(endpoint.id, 0),
|
||||||
|
"proxy": mask_proxy_password(endpoint.proxy),
|
||||||
}
|
}
|
||||||
endpoint_dict.pop("_sa_instance_state", None)
|
endpoint_dict.pop("_sa_instance_state", None)
|
||||||
result.append(ProviderEndpointResponse(**endpoint_dict))
|
result.append(ProviderEndpointResponse(**endpoint_dict))
|
||||||
@@ -202,6 +213,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
rate_limit=self.endpoint_data.rate_limit,
|
rate_limit=self.endpoint_data.rate_limit,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
config=self.endpoint_data.config,
|
config=self.endpoint_data.config,
|
||||||
|
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -215,12 +227,13 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in new_endpoint.__dict__.items()
|
for k, v in new_endpoint.__dict__.items()
|
||||||
if k not in {"api_format", "_sa_instance_state"}
|
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||||
}
|
}
|
||||||
return ProviderEndpointResponse(
|
return ProviderEndpointResponse(
|
||||||
**endpoint_dict,
|
**endpoint_dict,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
api_format=new_endpoint.api_format,
|
api_format=new_endpoint.api_format,
|
||||||
|
proxy=mask_proxy_password(new_endpoint.proxy),
|
||||||
total_keys=0,
|
total_keys=0,
|
||||||
active_keys=0,
|
active_keys=0,
|
||||||
)
|
)
|
||||||
@@ -259,12 +272,13 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in endpoint_obj.__dict__.items()
|
for k, v in endpoint_obj.__dict__.items()
|
||||||
if k not in {"api_format", "_sa_instance_state"}
|
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||||
}
|
}
|
||||||
return ProviderEndpointResponse(
|
return ProviderEndpointResponse(
|
||||||
**endpoint_dict,
|
**endpoint_dict,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
api_format=endpoint_obj.api_format,
|
api_format=endpoint_obj.api_format,
|
||||||
|
proxy=mask_proxy_password(endpoint_obj.proxy),
|
||||||
total_keys=total_keys,
|
total_keys=total_keys,
|
||||||
active_keys=active_keys,
|
active_keys=active_keys,
|
||||||
)
|
)
|
||||||
@@ -284,6 +298,17 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||||
|
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
|
||||||
|
if "proxy" in update_data:
|
||||||
|
if update_data["proxy"] is not None:
|
||||||
|
new_proxy = dict(update_data["proxy"])
|
||||||
|
# 只有当密码字段未提供时才保留原密码(空字符串视为显式清除)
|
||||||
|
if "password" not in new_proxy and endpoint.proxy:
|
||||||
|
old_password = endpoint.proxy.get("password")
|
||||||
|
if old_password:
|
||||||
|
new_proxy["password"] = old_password
|
||||||
|
update_data["proxy"] = new_proxy
|
||||||
|
# proxy 为 None 时保留,用于清除代理配置
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(endpoint, field, value)
|
setattr(endpoint, field, value)
|
||||||
endpoint.updated_at = datetime.now(timezone.utc)
|
endpoint.updated_at = datetime.now(timezone.utc)
|
||||||
@@ -311,12 +336,13 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
endpoint_dict = {
|
endpoint_dict = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in endpoint.__dict__.items()
|
for k, v in endpoint.__dict__.items()
|
||||||
if k not in {"api_format", "_sa_instance_state"}
|
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||||
}
|
}
|
||||||
return ProviderEndpointResponse(
|
return ProviderEndpointResponse(
|
||||||
**endpoint_dict,
|
**endpoint_dict,
|
||||||
provider_name=provider.name if provider else "Unknown",
|
provider_name=provider.name if provider else "Unknown",
|
||||||
api_format=endpoint.api_format,
|
api_format=endpoint.api_format,
|
||||||
|
proxy=mask_proxy_password(endpoint.proxy),
|
||||||
total_keys=total_keys,
|
total_keys=total_keys,
|
||||||
active_keys=active_keys,
|
active_keys=active_keys,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -466,7 +466,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
pool=config.http_pool_timeout,
|
pool=config.http_pool_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
http_client = httpx.AsyncClient(timeout=timeout_config, follow_redirects=True)
|
# 创建 HTTP 客户端(支持代理配置)
|
||||||
|
from src.clients.http_client import HTTPClientPool
|
||||||
|
|
||||||
|
http_client = HTTPClientPool.create_client_with_proxy(
|
||||||
|
proxy_config=endpoint.proxy,
|
||||||
|
timeout=timeout_config,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response_ctx = http_client.stream(
|
response_ctx = http_client.stream(
|
||||||
"POST", url, json=provider_payload, headers=provider_headers
|
"POST", url, json=provider_payload, headers=provider_headers
|
||||||
@@ -634,10 +640,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
||||||
f"模型={model} -> {mapped_model or '无映射'}")
|
f"模型={model} -> {mapped_model or '无映射'}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
# 创建 HTTP 客户端(支持代理配置)
|
||||||
timeout=float(endpoint.timeout),
|
from src.clients.http_client import HTTPClientPool
|
||||||
follow_redirects=True,
|
|
||||||
) as http_client:
|
http_client = HTTPClientPool.create_client_with_proxy(
|
||||||
|
proxy_config=endpoint.proxy,
|
||||||
|
timeout=httpx.Timeout(float(endpoint.timeout)),
|
||||||
|
)
|
||||||
|
async with http_client:
|
||||||
resp = await http_client.post(url, json=provider_payload, headers=provider_hdrs)
|
resp = await http_client.post(url, json=provider_payload, headers=provider_hdrs)
|
||||||
|
|
||||||
status_code = resp.status_code
|
status_code = resp.status_code
|
||||||
|
|||||||
@@ -454,7 +454,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
f"Key=***{key.api_key[-4:]}, "
|
f"Key=***{key.api_key[-4:]}, "
|
||||||
f"原始模型={ctx.model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
f"原始模型={ctx.model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
||||||
|
|
||||||
http_client = httpx.AsyncClient(timeout=timeout_config, follow_redirects=True)
|
# 创建 HTTP 客户端(支持代理配置)
|
||||||
|
from src.clients.http_client import HTTPClientPool
|
||||||
|
|
||||||
|
http_client = HTTPClientPool.create_client_with_proxy(
|
||||||
|
proxy_config=endpoint.proxy,
|
||||||
|
timeout=timeout_config,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response_ctx = http_client.stream(
|
response_ctx = http_client.stream(
|
||||||
"POST", url, json=provider_payload, headers=provider_headers
|
"POST", url, json=provider_payload, headers=provider_headers
|
||||||
@@ -1419,10 +1425,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
f"Key=***{key.api_key[-4:]}, "
|
f"Key=***{key.api_key[-4:]}, "
|
||||||
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
# 创建 HTTP 客户端(支持代理配置)
|
||||||
timeout=float(endpoint.timeout),
|
from src.clients.http_client import HTTPClientPool
|
||||||
follow_redirects=True,
|
|
||||||
) as http_client:
|
http_client = HTTPClientPool.create_client_with_proxy(
|
||||||
|
proxy_config=endpoint.proxy,
|
||||||
|
timeout=httpx.Timeout(float(endpoint.timeout)),
|
||||||
|
)
|
||||||
|
async with http_client:
|
||||||
resp = await http_client.post(url, json=provider_payload, headers=provider_headers)
|
resp = await http_client.post(url, json=provider_payload, headers=provider_headers)
|
||||||
|
|
||||||
status_code = resp.status_code
|
status_code = resp.status_code
|
||||||
|
|||||||
@@ -5,12 +5,55 @@
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def build_proxy_url(proxy_config: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
根据代理配置构建完整的代理 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_config: 代理配置字典,包含 url, username, password, enabled
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的代理 URL,如 socks5://user:pass@host:port
|
||||||
|
如果 enabled=False 或无配置,返回 None
|
||||||
|
"""
|
||||||
|
if not proxy_config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查 enabled 字段,默认为 True(兼容旧数据)
|
||||||
|
if not proxy_config.get("enabled", True):
|
||||||
|
return None
|
||||||
|
|
||||||
|
proxy_url = proxy_config.get("url")
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
username = proxy_config.get("username")
|
||||||
|
password = proxy_config.get("password")
|
||||||
|
|
||||||
|
# 只要有用户名就添加认证信息(密码可以为空)
|
||||||
|
if username:
|
||||||
|
parsed = urlparse(proxy_url)
|
||||||
|
# URL 编码用户名和密码,处理特殊字符(如 @, :, /)
|
||||||
|
encoded_username = quote(username, safe="")
|
||||||
|
encoded_password = quote(password, safe="") if password else ""
|
||||||
|
# 重新构建带认证的代理 URL
|
||||||
|
if encoded_password:
|
||||||
|
auth_proxy = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{parsed.netloc}"
|
||||||
|
else:
|
||||||
|
auth_proxy = f"{parsed.scheme}://{encoded_username}@{parsed.netloc}"
|
||||||
|
if parsed.path:
|
||||||
|
auth_proxy += parsed.path
|
||||||
|
return auth_proxy
|
||||||
|
|
||||||
|
return proxy_url
|
||||||
|
|
||||||
|
|
||||||
class HTTPClientPool:
|
class HTTPClientPool:
|
||||||
"""
|
"""
|
||||||
@@ -121,6 +164,44 @@ class HTTPClientPool:
|
|||||||
finally:
|
finally:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_client_with_proxy(
|
||||||
|
cls,
|
||||||
|
proxy_config: Optional[Dict[str, Any]] = None,
|
||||||
|
timeout: Optional[httpx.Timeout] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
"""
|
||||||
|
创建带代理配置的HTTP客户端
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_config: 代理配置字典,包含 url, username, password
|
||||||
|
timeout: 超时配置
|
||||||
|
**kwargs: 其他 httpx.AsyncClient 配置参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置好的 httpx.AsyncClient 实例
|
||||||
|
"""
|
||||||
|
config: Dict[str, Any] = {
|
||||||
|
"http2": False,
|
||||||
|
"verify": True,
|
||||||
|
"follow_redirects": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout:
|
||||||
|
config["timeout"] = timeout
|
||||||
|
else:
|
||||||
|
config["timeout"] = httpx.Timeout(10.0, read=300.0)
|
||||||
|
|
||||||
|
# 添加代理配置
|
||||||
|
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
||||||
|
if proxy_url:
|
||||||
|
config["proxy"] = proxy_url
|
||||||
|
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
|
||||||
|
|
||||||
|
config.update(kwargs)
|
||||||
|
return httpx.AsyncClient(**config)
|
||||||
|
|
||||||
|
|
||||||
# 便捷访问函数
|
# 便捷访问函数
|
||||||
def get_http_client() -> httpx.AsyncClient:
|
def get_http_client() -> httpx.AsyncClient:
|
||||||
|
|||||||
@@ -13,6 +13,42 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||||||
from src.core.enums import APIFormat, ProviderBillingType
|
from src.core.enums import APIFormat, ProviderBillingType
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyConfig(BaseModel):
|
||||||
|
"""代理配置"""
|
||||||
|
|
||||||
|
url: str = Field(..., description="代理 URL (http://, https://, socks5://)")
|
||||||
|
username: Optional[str] = Field(None, max_length=255, description="代理用户名")
|
||||||
|
password: Optional[str] = Field(None, max_length=500, description="代理密码")
|
||||||
|
enabled: bool = Field(True, description="是否启用代理(false 时保留配置但不使用)")
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def validate_proxy_url(cls, v: str) -> str:
|
||||||
|
"""验证代理 URL 格式"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
v = v.strip()
|
||||||
|
|
||||||
|
# 检查禁止的字符(防止注入)
|
||||||
|
if "\n" in v or "\r" in v:
|
||||||
|
raise ValueError("代理 URL 包含非法字符")
|
||||||
|
|
||||||
|
# 验证协议(不支持 SOCKS4)
|
||||||
|
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||||
|
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||||
|
|
||||||
|
# 验证 URL 结构
|
||||||
|
parsed = urlparse(v)
|
||||||
|
if not parsed.netloc:
|
||||||
|
raise ValueError("代理 URL 必须包含有效的 host")
|
||||||
|
|
||||||
|
# 禁止 URL 中内嵌认证信息,强制使用独立字段
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError("请勿在 URL 中包含用户名和密码,请使用独立的认证字段")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class CreateProviderRequest(BaseModel):
|
class CreateProviderRequest(BaseModel):
|
||||||
"""创建 Provider 请求"""
|
"""创建 Provider 请求"""
|
||||||
|
|
||||||
@@ -165,6 +201,7 @@ class CreateEndpointRequest(BaseModel):
|
|||||||
rpm_limit: Optional[int] = Field(None, ge=0, description="RPM 限制")
|
rpm_limit: Optional[int] = Field(None, ge=0, description="RPM 限制")
|
||||||
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
|
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
|
||||||
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
|
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
|
||||||
|
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -220,6 +257,7 @@ class UpdateEndpointRequest(BaseModel):
|
|||||||
rpm_limit: Optional[int] = Field(None, ge=0)
|
rpm_limit: Optional[int] = Field(None, ge=0)
|
||||||
concurrent_limit: Optional[int] = Field(None, ge=0)
|
concurrent_limit: Optional[int] = Field(None, ge=0)
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
|
||||||
|
|
||||||
# 复用验证器
|
# 复用验证器
|
||||||
_validate_name = field_validator("name")(CreateEndpointRequest.validate_name.__func__)
|
_validate_name = field_validator("name")(CreateEndpointRequest.validate_name.__func__)
|
||||||
|
|||||||
@@ -538,6 +538,9 @@ class ProviderEndpoint(Base):
|
|||||||
# 额外配置
|
# 额外配置
|
||||||
config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段)
|
config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段)
|
||||||
|
|
||||||
|
# 代理配置
|
||||||
|
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password}
|
||||||
|
|
||||||
# 时间戳
|
# 时间戳
|
||||||
created_at = Column(
|
created_at = Column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from src.models.admin_requests import ProxyConfig
|
||||||
|
|
||||||
# ========== ProviderEndpoint CRUD ==========
|
# ========== ProviderEndpoint CRUD ==========
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +32,9 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
# 额外配置
|
# 额外配置
|
||||||
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置(JSON)")
|
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置(JSON)")
|
||||||
|
|
||||||
|
# 代理配置
|
||||||
|
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
|
||||||
|
|
||||||
@field_validator("api_format")
|
@field_validator("api_format")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_api_format(cls, v: str) -> str:
|
def validate_api_format(cls, v: str) -> str:
|
||||||
@@ -64,6 +69,7 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
rate_limit: Optional[int] = Field(default=None, ge=1, description="速率限制")
|
rate_limit: Optional[int] = Field(default=None, ge=1, description="速率限制")
|
||||||
is_active: Optional[bool] = Field(default=None, description="是否启用")
|
is_active: Optional[bool] = Field(default=None, description="是否启用")
|
||||||
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
|
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
|
||||||
|
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
|
||||||
|
|
||||||
@field_validator("base_url")
|
@field_validator("base_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -104,6 +110,9 @@ class ProviderEndpointResponse(BaseModel):
|
|||||||
# 额外配置
|
# 额外配置
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# 代理配置(响应中密码已脱敏)
|
||||||
|
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置(密码已脱敏)")
|
||||||
|
|
||||||
# 统计(从 Keys 聚合)
|
# 统计(从 Keys 聚合)
|
||||||
total_keys: int = Field(default=0, description="总 Key 数量")
|
total_keys: int = Field(default=0, description="总 Key 数量")
|
||||||
active_keys: int = Field(default=0, description="活跃 Key 数量")
|
active_keys: int = Field(default=0, description="活跃 Key 数量")
|
||||||
|
|||||||
Reference in New Issue
Block a user