From 3e50c157bec05b4dc9b38820d7a926bbe3870f1b Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 18 Dec 2025 14:42:06 +0800 Subject: [PATCH 1/2] 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 --- ...2_add_proxy_field_to_provider_endpoints.py | 57 +++++++++++ frontend/src/api/endpoints/endpoints.ts | 11 +++ frontend/src/api/endpoints/types.ts | 5 + .../components/EndpointFormDialog.vue | 98 ++++++++++++++++++- src/api/admin/endpoints/routes.py | 4 + src/api/handlers/base/chat_handler_base.py | 20 +++- src/api/handlers/base/cli_handler_base.py | 20 +++- src/clients/http_client.py | 69 +++++++++++++ src/models/admin_requests.py | 19 ++++ src/models/database.py | 3 + src/models/endpoint_models.py | 9 ++ 11 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 alembic/versions/20251218_0631_f30f9936f6a2_add_proxy_field_to_provider_endpoints.py diff --git a/alembic/versions/20251218_0631_f30f9936f6a2_add_proxy_field_to_provider_endpoints.py b/alembic/versions/20251218_0631_f30f9936f6a2_add_proxy_field_to_provider_endpoints.py new file mode 100644 index 0000000..1e7e2df --- /dev/null +++ b/alembic/versions/20251218_0631_f30f9936f6a2_add_proxy_field_to_provider_endpoints.py @@ -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') diff --git a/frontend/src/api/endpoints/endpoints.ts b/frontend/src/api/endpoints/endpoints.ts index 642048f..9427e4d 100644 --- a/frontend/src/api/endpoints/endpoints.ts +++ b/frontend/src/api/endpoints/endpoints.ts @@ -1,6 +1,15 @@ import client from '../client' import type { ProviderEndpoint } from './types' +/** + * 代理配置类型 + */ +export interface ProxyConfig { + url: string + username?: string + password?: string +} + /** * 获取指定 Provider 的所有 Endpoints */ @@ -38,6 +47,7 @@ export async function createEndpoint( rate_limit?: number is_active?: boolean config?: Record + proxy?: ProxyConfig } ): Promise { const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data) @@ -63,6 +73,7 @@ export async function updateEndpoint( rate_limit: number is_active: boolean config: Record + proxy: ProxyConfig }> ): Promise { const response = await client.put(`/api/admin/endpoints/${endpointId}`, data) diff --git a/frontend/src/api/endpoints/types.ts b/frontend/src/api/endpoints/types.ts index ea8ee22..e901fae 100644 --- a/frontend/src/api/endpoints/types.ts +++ b/frontend/src/api/endpoints/types.ts @@ -41,6 +41,11 @@ export interface ProviderEndpoint { last_failure_at?: string is_active: boolean config?: Record + proxy?: { + url: string + username?: string + password?: string + } total_keys: number active_keys: number created_at: string diff --git a/frontend/src/features/providers/components/EndpointFormDialog.vue b/frontend/src/features/providers/components/EndpointFormDialog.vue index c2a8c0e..6b8f83c 100644 --- a/frontend/src/features/providers/components/EndpointFormDialog.vue +++ b/frontend/src/features/providers/components/EndpointFormDialog.vue @@ -132,6 +132,61 @@ + + +
+
+

+ 代理配置 +

+ +
+ +
+
+ + +

+ 支持 HTTP、HTTPS、SOCKS4、SOCKS5 代理 +

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + + diff --git a/src/api/admin/endpoints/routes.py b/src/api/admin/endpoints/routes.py index ff28b33..3d22e67 100644 --- a/src/api/admin/endpoints/routes.py +++ b/src/api/admin/endpoints/routes.py @@ -5,7 +5,7 @@ ProviderEndpoint CRUD 管理 API import uuid from dataclasses import dataclass from datetime import datetime, timezone -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import and_, func @@ -27,6 +27,16 @@ router = APIRouter(tags=["Endpoint Management"]) 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]) async def list_provider_endpoints( provider_id: str, @@ -153,6 +163,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter): "api_format": endpoint.api_format, "total_keys": total_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) result.append(ProviderEndpointResponse(**endpoint_dict)) @@ -216,12 +227,13 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter): endpoint_dict = { k: v 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( **endpoint_dict, provider_name=provider.name, api_format=new_endpoint.api_format, + proxy=mask_proxy_password(new_endpoint.proxy), total_keys=0, active_keys=0, ) @@ -260,12 +272,13 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter): endpoint_dict = { k: v 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( **endpoint_dict, provider_name=provider.name, api_format=endpoint_obj.api_format, + proxy=mask_proxy_password(endpoint_obj.proxy), total_keys=total_keys, active_keys=active_keys, ) @@ -285,9 +298,17 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter): raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在") 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"]) + # 把 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(): setattr(endpoint, field, value) endpoint.updated_at = datetime.now(timezone.utc) @@ -315,12 +336,13 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter): endpoint_dict = { k: v 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( **endpoint_dict, provider_name=provider.name if provider else "Unknown", api_format=endpoint.api_format, + proxy=mask_proxy_password(endpoint.proxy), total_keys=total_keys, active_keys=active_keys, ) diff --git a/src/clients/http_client.py b/src/clients/http_client.py index f300503..9d419dd 100644 --- a/src/clients/http_client.py +++ b/src/clients/http_client.py @@ -5,7 +5,7 @@ from contextlib import asynccontextmanager from typing import Any, Dict, Optional -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx @@ -17,14 +17,19 @@ def build_proxy_url(proxy_config: Dict[str, Any]) -> Optional[str]: 根据代理配置构建完整的代理 URL Args: - proxy_config: 代理配置字典,包含 url, username, password + 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 @@ -32,10 +37,17 @@ def build_proxy_url(proxy_config: Dict[str, Any]) -> Optional[str]: username = proxy_config.get("username") password = proxy_config.get("password") - if username and password: + # 只要有用户名就添加认证信息(密码可以为空) + if username: parsed = urlparse(proxy_url) + # URL 编码用户名和密码,处理特殊字符(如 @, :, /) + encoded_username = quote(username, safe="") + encoded_password = quote(password, safe="") if password else "" # 重新构建带认证的代理 URL - auth_proxy = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}" + 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 diff --git a/src/models/admin_requests.py b/src/models/admin_requests.py index bd0c78d..35eb995 100644 --- a/src/models/admin_requests.py +++ b/src/models/admin_requests.py @@ -19,14 +19,33 @@ 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 not re.match(r"^(http|https|socks5|socks4)://", v, re.IGNORECASE): - raise ValueError("代理 URL 必须以 http://, https://, socks5:// 或 socks4:// 开头") + + # 检查禁止的字符(防止注入) + 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 diff --git a/src/models/endpoint_models.py b/src/models/endpoint_models.py index e97f6cb..cbbf025 100644 --- a/src/models/endpoint_models.py +++ b/src/models/endpoint_models.py @@ -110,8 +110,8 @@ class ProviderEndpointResponse(BaseModel): # 额外配置 config: Optional[Dict[str, Any]] = None - # 代理配置 - proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置") + # 代理配置(响应中密码已脱敏) + proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置(密码已脱敏)") # 统计(从 Keys 聚合) total_keys: int = Field(default=0, description="总 Key 数量")