Merge pull request #29 from fawney19/feature/proxy-support

feat: add HTTP/SOCKS5 proxy support for API endpoints
This commit is contained in:
fawney19
2025-12-18 16:18:25 +08:00
committed by GitHub
11 changed files with 474 additions and 23 deletions

View File

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

View File

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

View File

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

View File

@@ -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"
>
支持 HTTPHTTPSSOCKS5 代理
</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>

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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