feat: add HTTP/SOCKS5 proxy support for API endpoints

- Add proxy field to ProviderEndpoint database model with migration
- Add ProxyConfig Pydantic model for proxy URL validation
- Extend HTTP client pool with create_client_with_proxy method
- Integrate proxy configuration in chat_handler_base.py and cli_handler_base.py
- Update admin API endpoints to support proxy configuration CRUD
- Add proxy configuration UI in frontend EndpointFormDialog

Fixes #28
This commit is contained in:
fawney19
2025-12-18 14:42:06 +08:00
parent 21587449c8
commit 3e50c157be
11 changed files with 300 additions and 15 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,6 +1,15 @@
import client from '../client' import client from '../client'
import type { ProviderEndpoint } from './types' import type { ProviderEndpoint } from './types'
/**
* 代理配置类型
*/
export interface ProxyConfig {
url: string
username?: string
password?: string
}
/** /**
* 获取指定 Provider 的所有 Endpoints * 获取指定 Provider 的所有 Endpoints
*/ */
@@ -38,6 +47,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
} }
): 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 +73,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
}> }>
): 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

@@ -41,6 +41,11 @@ 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?: {
url: string
username?: string
password?: string
}
total_keys: number total_keys: number
active_keys: number active_keys: number
created_at: string created_at: string

View File

@@ -132,6 +132,61 @@
</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>
<label class="flex items-center gap-2 text-sm">
<input
v-model="proxyEnabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300"
>
启用代理
</label>
</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"
/>
<p class="text-xs text-muted-foreground">
支持 HTTPHTTPSSOCKS4SOCKS5 代理
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="proxy_username">用户名可选</Label>
<Input
id="proxy_username"
v-model="form.proxy_username"
placeholder="代理认证用户名"
/>
</div>
<div class="space-y-2">
<Label for="proxy_password">密码可选</Label>
<Input
id="proxy_password"
v-model="form.proxy_password"
type="password"
placeholder="代理认证密码"
/>
</div>
</div>
</div>
</div>
</form> </form>
<template #footer> <template #footer>
@@ -194,6 +249,7 @@ 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 internalOpen = computed(() => props.modelValue) const internalOpen = computed(() => props.modelValue)
@@ -207,7 +263,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 格式列表
@@ -252,14 +312,20 @@ 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
} }
// 加载端点数据(编辑模式) // 加载端点数据(编辑模式)
function loadEndpointData() { function loadEndpointData() {
if (!props.endpoint) return if (!props.endpoint) return
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string } | 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 +334,14 @@ 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 || '',
} }
// 如果有代理配置,启用代理开关
proxyEnabled.value = !!proxy?.url
} }
// 使用 useFormDialog 统一处理对话框逻辑 // 使用 useFormDialog 统一处理对话框逻辑
@@ -282,12 +354,26 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm, resetForm,
}) })
// 构建代理配置
function buildProxyConfig() {
if (!proxyEnabled.value || !form.value.proxy_url) {
return undefined
}
return {
url: form.value.proxy_url,
username: form.value.proxy_username || undefined,
password: form.value.proxy_password || undefined,
}
}
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
if (!props.provider && !props.endpoint) return if (!props.provider && !props.endpoint) 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 +383,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 +400,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('端点创建成功', '成功')

View File

@@ -202,6 +202,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,
) )
@@ -284,6 +285,9 @@ 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 存储
if "proxy" in update_data and update_data["proxy"] is not None:
update_data["proxy"] = dict(update_data["proxy"])
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)

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,43 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import 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
Returns:
完整的代理 URL如 socks5://user:pass@host:port
"""
if not proxy_config:
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 and password:
parsed = urlparse(proxy_url)
# 重新构建带认证的代理 URL
auth_proxy = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}"
if parsed.path:
auth_proxy += parsed.path
return auth_proxy
return proxy_url
class HTTPClientPool: class HTTPClientPool:
""" """
@@ -121,6 +152,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,23 @@ 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="代理密码")
@field_validator("url")
@classmethod
def validate_proxy_url(cls, v: str) -> str:
"""验证代理 URL 格式"""
v = v.strip()
if not re.match(r"^(http|https|socks5|socks4)://", v, re.IGNORECASE):
raise ValueError("代理 URL 必须以 http://, https://, socks5:// 或 socks4:// 开头")
return v
class CreateProviderRequest(BaseModel): class CreateProviderRequest(BaseModel):
"""创建 Provider 请求""" """创建 Provider 请求"""
@@ -165,6 +182,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 +238,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 数量")