From dfe30184287ceb7f7b980155af953f8ae6632394 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 26 Jun 2026 13:33:15 +0800 Subject: [PATCH 1/5] feat: add per-account proxy pool with bulk import and auto-assign Adds a proxy pool system that allows importing proxies in bulk (IP:port:username:password format) and automatically distributing them round-robin across all accounts, so each account uses an independent outbound IP. - New service: proxy_pool_service with import/delete/assign/clear - Storage layer: load_proxy_pool/save_proxy_pool on all backends - Network layer: account_proxy parameter takes priority over global proxy - All providers (GPT/Grok/Gemini) now use per-account proxy when set - API endpoints: GET/POST/DELETE /api/proxy-pool, /assign, /clear - Frontend: proxy pool management card in Settings page --- api/system.py | 40 +++ services/account_service.py | 1 + services/network/client.py | 8 +- services/openai_backend_api.py | 8 +- services/protocol/anthropic_v1_messages.py | 4 +- services/protocol/openai_search.py | 3 +- services/protocol/openai_v1_models.py | 17 +- services/providers/gemini/accounts.py | 2 +- services/providers/gemini/client.py | 8 +- services/providers/gpt/runtime.py | 14 +- services/providers/grok/client.py | 15 +- services/proxy_pool_service.py | 155 +++++++++++ services/proxy_service.py | 4 +- services/storage/base.py | 10 + services/storage/database_storage.py | 32 +++ services/storage/git_storage.py | 17 ++ services/storage/json_storage.py | 8 + test/test_account_api_sanitization.py | 6 + test/test_account_provider.py | 2 +- test/test_grok_client_parity.py | 6 +- test/test_grok_provider.py | 14 +- test/test_provider_models.py | 5 +- test/test_remote_account_api.py | 6 + test/test_turnstile_retry.py | 3 +- .../settings/components/proxy-pool-card.tsx | 262 ++++++++++++++++++ web/src/app/settings/page.tsx | 2 + web/src/lib/api.ts | 56 ++++ 27 files changed, 673 insertions(+), 35 deletions(-) create mode 100644 services/proxy_pool_service.py create mode 100644 web/src/app/settings/components/proxy-pool-card.tsx diff --git a/api/system.py b/api/system.py index 45cf853..6142d1a 100644 --- a/api/system.py +++ b/api/system.py @@ -14,6 +14,7 @@ from services.image_storage_service import ImageStorageError, image_storage_service from services.image_tags_service import delete_tag, get_all_tags, set_tags from services.log_service import log_service +from services.proxy_pool_service import proxy_pool_service from services.proxy_service import test_proxy @@ -43,6 +44,12 @@ class LogDeleteRequest(BaseModel): class BackupDeleteRequest(BaseModel): key: str = "" +class ProxyPoolImportRequest(BaseModel): + proxies: str = "" + +class ProxyPoolDeleteRequest(BaseModel): + ids: list[str] = [] + def create_router(app_version: str) -> APIRouter: router = APIRouter() @@ -233,4 +240,37 @@ async def delete_image_tag(tag: str, authorization: str | None = Header(default= count = delete_tag(tag) return {"ok": True, "removed_from": count} + @router.get("/api/proxy-pool") + async def list_proxy_pool(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"items": proxy_pool_service.list_items()} + + @router.post("/api/proxy-pool") + async def import_proxy_pool(body: ProxyPoolImportRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + text = (body.proxies or "").strip() + if not text: + raise HTTPException(status_code=400, detail={"error": "proxies is required"}) + result = proxy_pool_service.import_proxies(text) + return {**result, "items": proxy_pool_service.list_items()} + + @router.delete("/api/proxy-pool") + async def delete_proxy_pool(body: ProxyPoolDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not body.ids: + result = proxy_pool_service.clear_all() + else: + result = proxy_pool_service.delete_proxies(body.ids) + return {**result, "items": proxy_pool_service.list_items()} + + @router.post("/api/proxy-pool/assign") + async def assign_proxy_pool(authorization: str | None = Header(default=None)): + require_admin(authorization) + return await run_in_threadpool(proxy_pool_service.assign_to_accounts) + + @router.post("/api/proxy-pool/clear") + async def clear_proxy_pool_assignments(authorization: str | None = Header(default=None)): + require_admin(authorization) + return await run_in_threadpool(proxy_pool_service.clear_assignments) + return router diff --git a/services/account_service.py b/services/account_service.py index 9a24905..b992303 100644 --- a/services/account_service.py +++ b/services/account_service.py @@ -384,6 +384,7 @@ def _normalize_account(self, item: dict) -> dict | None: provider_strategy = account_strategy(normalized["provider"]) if normalized["provider"] in {GROK_PROVIDER, GEMINI_PROVIDER}: normalized = provider_strategy.normalize_account(normalized) + normalized["proxy"] = str(normalized.get("proxy") or "").strip() normalized["success"] = int(normalized.get("success") or 0) normalized["fail"] = int(normalized.get("fail") or 0) normalized["last_used_at"] = normalized.get("last_used_at") diff --git a/services/network/client.py b/services/network/client.py index 1364453..1c83bd7 100644 --- a/services/network/client.py +++ b/services/network/client.py @@ -2,16 +2,16 @@ from typing import Any -def build_session_kwargs(*, impersonate: str | None = None, verify: bool = True, **session_kwargs: Any) -> dict[str, object]: +def build_session_kwargs(*, account_proxy: str = "", impersonate: str | None = None, verify: bool = True, **session_kwargs: Any) -> dict[str, object]: if impersonate: session_kwargs["impersonate"] = impersonate session_kwargs["verify"] = verify from services.proxy_service import proxy_settings - return proxy_settings.build_session_kwargs(**session_kwargs) + return proxy_settings.build_session_kwargs(account_proxy=account_proxy, **session_kwargs) -def create_session(*, impersonate: str | None = None, verify: bool = True, **session_kwargs: Any): +def create_session(*, account_proxy: str = "", impersonate: str | None = None, verify: bool = True, **session_kwargs: Any): from curl_cffi import requests - return requests.Session(**build_session_kwargs(impersonate=impersonate, verify=verify, **session_kwargs)) + return requests.Session(**build_session_kwargs(account_proxy=account_proxy, impersonate=impersonate, verify=verify, **session_kwargs)) diff --git a/services/openai_backend_api.py b/services/openai_backend_api.py index 72f2413..8440a2c 100644 --- a/services/openai_backend_api.py +++ b/services/openai_backend_api.py @@ -69,16 +69,18 @@ class OpenAIBackendAPI: - 协议兼容转换放在 `services.protocol` """ - def __init__(self, access_token: str = "") -> None: + def __init__(self, access_token: str = "", *, account_proxy: str = "") -> None: """初始化后端客户端。 参数: - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。 + - `account_proxy`:可选。账号级代理 URL,优先于全局代理。 """ self.base_url = "https://chatgpt.com" self.client_version = DEFAULT_CLIENT_VERSION self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER self.access_token = access_token + self.account_proxy = account_proxy self.network_profile = self._build_network_profile() self.fp = self.network_profile.as_fingerprint() self.user_agent = self.fp["user-agent"] @@ -86,7 +88,7 @@ def __init__(self, access_token: str = "") -> None: self.session_id = self.fp["oai-session-id"] self.pow_script_sources: list[str] = [] self.pow_data_build = "" - self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) + self.session = create_session(account_proxy=self.account_proxy, impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) self.session.headers.update(build_chatgpt_web_headers( self.network_profile, base_url=self.base_url, @@ -155,7 +157,7 @@ def _refresh_session(self) -> None: self.device_id = self.fp["oai-device-id"] self.session_id = self.fp["oai-session-id"] - self.session = create_session(impersonate=fresh.impersonate, verify=fresh.verify) + self.session = create_session(account_proxy=self.account_proxy, impersonate=fresh.impersonate, verify=fresh.verify) self.session.headers.update(build_chatgpt_web_headers( fresh, base_url=self.base_url, diff --git a/services/protocol/anthropic_v1_messages.py b/services/protocol/anthropic_v1_messages.py index e1a075d..6d19078 100644 --- a/services/protocol/anthropic_v1_messages.py +++ b/services/protocol/anthropic_v1_messages.py @@ -110,8 +110,10 @@ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], def message_request(body: dict[str, Any]) -> MessageRequest: payload = preprocess_payload(dict(body)) + token = account_service.get_text_access_token() + account = account_service.get_account(token) or {} return MessageRequest( - backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()), + backend=OpenAIBackendAPI(access_token=token, account_proxy=str(account.get("proxy") or "")), messages=normalize_messages(payload.get("messages"), payload.get("system")), model=str(payload.get("model") or "auto").strip() or "auto", tools=payload.get("tools"), diff --git a/services/protocol/openai_search.py b/services/protocol/openai_search.py index 996554d..30dd4aa 100644 --- a/services/protocol/openai_search.py +++ b/services/protocol/openai_search.py @@ -39,7 +39,8 @@ def handle(body: dict[str, Any]) -> dict[str, Any]: token = account_service.get_text_access_token(provider="gpt") if not token: raise HTTPException(status_code=429, detail={"error": "no available text account"}) - backend = OpenAIBackendAPI(token) + account = account_service.get_account(token) or {} + backend = OpenAIBackendAPI(token, account_proxy=str(account.get("proxy") or "")) try: result = backend.search(prompt, model=model) finally: diff --git a/services/protocol/openai_v1_models.py b/services/protocol/openai_v1_models.py index 318176f..9b6e7f6 100644 --- a/services/protocol/openai_v1_models.py +++ b/services/protocol/openai_v1_models.py @@ -30,8 +30,8 @@ def _empty_model_result() -> dict[str, Any]: return {"object": "list", "data": []} -def _fetch_chatgpt_models(OpenAIBackendAPI: type, access_token: str = "") -> dict[str, Any]: - with OpenAIBackendAPI(access_token) as backend: +def _fetch_chatgpt_models(OpenAIBackendAPI: type, access_token: str = "", account_proxy: str = "") -> dict[str, Any]: + with OpenAIBackendAPI(access_token, account_proxy=account_proxy) as backend: return backend.list_models() @@ -43,6 +43,17 @@ def _get_gpt_access_token() -> str: return "" +def _get_account_proxy(token: str) -> str: + if not token: + return "" + try: + from services.account_service import account_service + account = account_service.get_account(token) + return str((account or {}).get("proxy") or "") + except Exception: + return "" + + def list_models() -> dict[str, Any]: try: from services.openai_backend_api import OpenAIBackendAPI @@ -52,7 +63,7 @@ def list_models() -> dict[str, Any]: access_token = _get_gpt_access_token() if access_token: try: - result = _fetch_chatgpt_models(OpenAIBackendAPI, access_token) + result = _fetch_chatgpt_models(OpenAIBackendAPI, access_token, _get_account_proxy(access_token)) except Exception: try: result = _fetch_chatgpt_models(OpenAIBackendAPI) diff --git a/services/providers/gemini/accounts.py b/services/providers/gemini/accounts.py index 947d756..21d8c3b 100644 --- a/services/providers/gemini/accounts.py +++ b/services/providers/gemini/accounts.py @@ -289,7 +289,7 @@ def validate_remote_info(access_token: str, account: dict[str, Any] | None = Non if access_token: source.setdefault("access_token", access_token) cookie_header_value = account_cookie_header(source) - with GeminiWebClient(cookie_header_value, source.get("user_agent")) as client: + with GeminiWebClient(cookie_header_value, source.get("user_agent"), account_proxy=str(source.get("proxy") or "")) as client: client.rotate_psidts() session_token = client.bootstrap_session_token() return gemini_session_writeback(source, client.cookie_header, session_token) diff --git a/services/providers/gemini/client.py b/services/providers/gemini/client.py index 7144d4b..a1b847d 100644 --- a/services/providers/gemini/client.py +++ b/services/providers/gemini/client.py @@ -459,11 +459,11 @@ def parse_web_response_text(raw_text: str) -> object: class GeminiWebClient: - def __init__(self, cookie_header: str, user_agent: str | None = None) -> None: + def __init__(self, cookie_header: str, user_agent: str | None = None, account_proxy: str = "") -> None: self.cookie_header = cookie_header self.user_agent = user_agent or GEMINI_BROWSER_USER_AGENT self.session_token = "" - self.session = create_session() + self.session = create_session(account_proxy=account_proxy) def __enter__(self) -> "GeminiWebClient": return self @@ -587,7 +587,7 @@ def fetch_authenticated_init_body() -> str: return "" account = account_service.get_account(access_token) or {"access_token": access_token, "provider": "gemini"} cookie_header = account_cookie_header(account) - with GeminiWebClient(cookie_header, account.get("user_agent")) as client: + with GeminiWebClient(cookie_header, account.get("user_agent"), account_proxy=str(account.get("proxy") or "")) as client: init_body = client.fetch_init_body() persist_gemini_session(account_service, access_token, account, client.cookie_header) return init_body @@ -614,7 +614,7 @@ def chat_completion(body: dict[str, Any], spec: ModelSpec, messages: list[dict[s if session_token: payload["session_token"] = session_token try: - with GeminiWebClient(cookie_header, account.get("user_agent")) as client: + with GeminiWebClient(cookie_header, account.get("user_agent"), account_proxy=str(account.get("proxy") or "")) as client: response_payload = client.generate(payload) persist_gemini_session(account_service, access_token, account, client.cookie_header, client.session_token) except GeminiWebError as exc: diff --git a/services/providers/gpt/runtime.py b/services/providers/gpt/runtime.py index f14e8d5..dc464cb 100644 --- a/services/providers/gpt/runtime.py +++ b/services/providers/gpt/runtime.py @@ -380,8 +380,16 @@ def conversation_events( yield from iter_conversation_payloads(payloads, history_text, history_messages) +def _account_proxy(token: str) -> str: + if not token: + return "" + account = account_service.get_account(token) + return str((account or {}).get("proxy") or "") + + def text_backend() -> OpenAIBackendAPI: - return OpenAIBackendAPI(access_token=account_service.get_text_access_token()) + token = account_service.get_text_access_token() + return OpenAIBackendAPI(access_token=token, account_proxy=_account_proxy(token)) def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]: @@ -394,7 +402,7 @@ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) if token: attempted_tokens.add(token) try: - active_backend = OpenAIBackendAPI(access_token=token) + active_backend = OpenAIBackendAPI(access_token=token, account_proxy=_account_proxy(token)) try: for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt): if event.get("type") != "conversation.delta": @@ -521,7 +529,7 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima emitted_for_token = False returned_message = False returned_result = False - backend = OpenAIBackendAPI(access_token=token) + backend = OpenAIBackendAPI(access_token=token, account_proxy=_account_proxy(token)) try: try: for output in stream_image_outputs(backend, request, index, request.n): diff --git a/services/providers/grok/client.py b/services/providers/grok/client.py index 07b5f9c..e2b7e70 100644 --- a/services/providers/grok/client.py +++ b/services/providers/grok/client.py @@ -665,10 +665,12 @@ def _raise_console_upstream_error(access_token: str, upstream_status: int, respo class GrokConsoleClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account: dict[str, Any] | None = None) -> None: self.access_token = access_token + self.account = account if isinstance(account, dict) else None + self.account_proxy = str((self.account or {}).get("proxy") or "") self.network_profile = _grok_console_profile() - self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) + self.session = create_session(account_proxy=self.account_proxy, impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) def close(self) -> None: self.session.close() @@ -1838,9 +1840,10 @@ class GrokAppChatClient: def __init__(self, access_token: str, account: dict[str, Any] | None = None) -> None: self.access_token = access_token self.account = account if isinstance(account, dict) else None + self.account_proxy = str((self.account or {}).get("proxy") or "") self.network_profile = _grok_app_chat_profile() impersonate = _app_chat_impersonate(self.network_profile, self.account) - self.session = create_session(impersonate=impersonate, verify=self.network_profile.verify) + self.session = create_session(account_proxy=self.account_proxy, impersonate=impersonate, verify=self.network_profile.verify) def close(self) -> None: self.session.close() @@ -2421,8 +2424,9 @@ def console_chat_completion(body: dict[str, Any], spec: ModelSpec, messages: lis access_token = account_service.get_grok_console_access_token() if not access_token: raise HTTPException(status_code=503, detail={"error": "no available Grok account"}) + account = account_service.get_account(access_token, provider="grok") try: - with GrokConsoleClient(access_token) as client: + with GrokConsoleClient(access_token, account) as client: response_json = client.create_response(payload) except GrokConsoleError as exc: account_service.mark_grok_console_used(access_token, success=False) @@ -2442,8 +2446,9 @@ def console_chat_completion_events(body: dict[str, Any], spec: ModelSpec, messag access_token = account_service.get_grok_console_access_token() if not access_token: raise HTTPException(status_code=503, detail={"error": "no available Grok account"}) + account = account_service.get_account(access_token, provider="grok") try: - with GrokConsoleClient(access_token) as client: + with GrokConsoleClient(access_token, account) as client: for event in client.stream_response(payload): yield event except GrokConsoleError as exc: diff --git a/services/proxy_pool_service.py b/services/proxy_pool_service.py new file mode 100644 index 0000000..4ebf352 --- /dev/null +++ b/services/proxy_pool_service.py @@ -0,0 +1,155 @@ +"""Proxy pool management: import, list, delete, assign to accounts.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from threading import Lock +from typing import Any +from urllib.parse import quote as url_quote + +from services.config import config +from services.storage.base import StorageBackend + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _parse_proxy_line(line: str) -> dict[str, Any] | None: + """Parse 'IP:port:username:password' into a proxy pool item.""" + parts = line.strip().split(":") + if len(parts) < 2: + return None + if len(parts) == 2: + host, port = parts[0], parts[1] + username, password = "", "" + elif len(parts) == 4: + host, port, username, password = parts + else: + host = parts[0] + port = parts[1] + username = parts[2] + password = ":".join(parts[3:]) + host = host.strip() + port = port.strip() + username = username.strip() + password = password.strip() + if not host or not port: + return None + try: + port_int = int(port) + except ValueError: + return None + if port_int < 1 or port_int > 65535: + return None + if username and password: + url = f"http://{url_quote(username, safe='')}:{url_quote(password, safe='')}@{host}:{port_int}" + else: + url = f"http://{host}:{port_int}" + return { + "id": str(uuid.uuid4()), + "url": url, + "host": host, + "port": port_int, + "username": username, + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + } + + +class ProxyPoolService: + def __init__(self, storage: StorageBackend): + self._storage = storage + self._lock = Lock() + self._items: list[dict[str, Any]] = self._storage.load_proxy_pool() + + def list_items(self) -> list[dict[str, Any]]: + with self._lock: + return list(self._items) + + def import_proxies(self, text: str) -> dict[str, Any]: + """Import proxies from text (one per line, format: IP:port:user:pass).""" + lines = [line.strip() for line in text.strip().splitlines() if line.strip()] + added = 0 + skipped = 0 + existing_urls = set() + with self._lock: + existing_urls = {item["url"] for item in self._items} + for line in lines: + parsed = _parse_proxy_line(line) + if parsed is None: + skipped += 1 + continue + if parsed["url"] in existing_urls: + skipped += 1 + continue + self._items.append(parsed) + existing_urls.add(parsed["url"]) + added += 1 + self._save() + return {"added": added, "skipped": skipped, "total": len(self._items)} + + def delete_proxies(self, ids: list[str]) -> dict[str, Any]: + """Delete proxies by their IDs.""" + id_set = set(ids) + with self._lock: + before = len(self._items) + self._items = [item for item in self._items if item["id"] not in id_set] + removed = before - len(self._items) + self._save() + return {"removed": removed, "total": len(self._items)} + + def clear_all(self) -> dict[str, Any]: + """Remove all proxies from the pool.""" + with self._lock: + removed = len(self._items) + self._items = [] + self._save() + return {"removed": removed, "total": 0} + + def assign_to_accounts(self) -> dict[str, Any]: + """Assign proxies round-robin to all accounts.""" + from services.account_service import account_service + + with self._lock: + pool = list(self._items) + + if not pool: + return {"assigned": 0, "error": "代理池为空,请先导入代理"} + + accounts = account_service.list_accounts() + if not accounts: + return {"assigned": 0, "error": "没有账号可分配"} + + assigned = 0 + for i, account in enumerate(accounts): + proxy_item = pool[i % len(pool)] + access_token = _clean(account.get("access_token")) + if not access_token: + continue + account_service.update_account(access_token, {"proxy": proxy_item["url"]}, provider=account.get("provider")) + assigned += 1 + + return {"assigned": assigned, "total_proxies": len(pool), "total_accounts": len(accounts)} + + def clear_assignments(self) -> dict[str, Any]: + """Clear proxy assignments from all accounts.""" + from services.account_service import account_service + + accounts = account_service.list_accounts() + cleared = 0 + for account in accounts: + access_token = _clean(account.get("access_token")) + if not access_token: + continue + if _clean(account.get("proxy")): + account_service.update_account(access_token, {"proxy": ""}, provider=account.get("provider")) + cleared += 1 + + return {"cleared": cleared} + + def _save(self) -> None: + self._storage.save_proxy_pool(self._items) + + +proxy_pool_service = ProxyPoolService(config.get_storage_backend()) diff --git a/services/proxy_service.py b/services/proxy_service.py index c3587af..59a473f 100644 --- a/services/proxy_service.py +++ b/services/proxy_service.py @@ -11,8 +11,8 @@ class ProxySettingsStore: - def build_session_kwargs(self, **session_kwargs) -> dict[str, object]: - proxy = config.get_proxy_settings() + def build_session_kwargs(self, *, account_proxy: str = "", **session_kwargs) -> dict[str, object]: + proxy = account_proxy or config.get_proxy_settings() if proxy: session_kwargs["proxy"] = proxy return session_kwargs diff --git a/services/storage/base.py b/services/storage/base.py index fe4e345..24228a8 100644 --- a/services/storage/base.py +++ b/services/storage/base.py @@ -37,6 +37,16 @@ def save_settings(self, settings: dict[str, Any]) -> None: """保存全局设置""" pass + @abstractmethod + def load_proxy_pool(self) -> list[dict[str, Any]]: + """加载代理池数据""" + pass + + @abstractmethod + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + """保存代理池数据""" + pass + @abstractmethod def health_check(self) -> dict[str, Any]: """健康检查,返回存储后端状态""" diff --git a/services/storage/database_storage.py b/services/storage/database_storage.py index 60bd781..cfd0ecb 100644 --- a/services/storage/database_storage.py +++ b/services/storage/database_storage.py @@ -79,6 +79,38 @@ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: """保存鉴权密钥数据到数据库""" self._save_rows(AuthKeyModel, auth_keys, "id", "key_id") + def load_proxy_pool(self) -> list[dict[str, Any]]: + """从数据库加载代理池数据""" + session = self.Session() + try: + row = session.query(AppSettingModel).filter_by(key="proxy_pool").one_or_none() + if row is None: + return [] + try: + data = json.loads(row.data) + except json.JSONDecodeError: + return [] + return data if isinstance(data, list) else [] + finally: + session.close() + + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + """保存代理池数据到数据库""" + session = self.Session() + try: + row = session.query(AppSettingModel).filter_by(key="proxy_pool").one_or_none() + data = json.dumps(items, ensure_ascii=False) + if row is None: + session.add(AppSettingModel(key="proxy_pool", data=data)) + else: + row.data = data + session.commit() + except Exception as e: + session.rollback() + raise e + finally: + session.close() + def load_settings(self) -> dict[str, Any]: """从数据库加载全局设置""" session = self.Session() diff --git a/services/storage/git_storage.py b/services/storage/git_storage.py index f3fd54b..67800f7 100644 --- a/services/storage/git_storage.py +++ b/services/storage/git_storage.py @@ -119,6 +119,23 @@ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: print(f"[git-storage] save failed: {e}") raise e + def load_proxy_pool(self) -> list[dict[str, Any]]: + """从 Git 仓库加载代理池数据""" + try: + return self._load_json_file("proxy_pool.json") + except (FileNotFoundError, json.JSONDecodeError): + return [] + except Exception: + return [] + + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + """保存代理池数据到 Git 仓库""" + try: + self._save_json_file("proxy_pool.json", items, "Update proxy pool data") + except Exception as e: + print(f"[git-storage] save proxy pool failed: {e}") + raise e + def load_settings(self) -> dict[str, Any]: """从 Git 仓库加载全局设置""" try: diff --git a/services/storage/json_storage.py b/services/storage/json_storage.py index c28b4f7..76086d7 100644 --- a/services/storage/json_storage.py +++ b/services/storage/json_storage.py @@ -82,6 +82,14 @@ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: encoding="utf-8", ) + def load_proxy_pool(self) -> list[dict[str, Any]]: + """从 JSON 文件加载代理池数据""" + return self._load_json_list(self.file_path.with_name("proxy_pool.json")) + + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + """保存代理池数据到 JSON 文件""" + self._save_json_list(self.file_path.with_name("proxy_pool.json"), items) + def load_settings(self) -> dict[str, Any]: """从 JSON 文件加载全局设置""" return self._load_json_object(self.settings_path) diff --git a/test/test_account_api_sanitization.py b/test/test_account_api_sanitization.py index 8ed76c5..ccddd57 100644 --- a/test/test_account_api_sanitization.py +++ b/test/test_account_api_sanitization.py @@ -315,6 +315,12 @@ def load_settings(self) -> dict[str, Any]: def save_settings(self, settings: dict[str, Any]) -> None: pass + def load_proxy_pool(self) -> list[dict[str, Any]]: + return [] + + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + pass + def health_check(self) -> dict[str, Any]: return {"ok": True} diff --git a/test/test_account_provider.py b/test/test_account_provider.py index 521fb24..e54256e 100644 --- a/test/test_account_provider.py +++ b/test/test_account_provider.py @@ -350,7 +350,7 @@ def test_gemini_refresh_by_sanitized_row_id_uses_client_helpers(self) -> None: self.assertTrue(strategy.supports_refresh(accounts[0])) self.assertEqual(result["refreshed"], 1) self.assertEqual(result["errors"], []) - client_class.assert_called_once_with("__Secure-1PSID=psid; __Secure-1PSIDTS=old-psidts", None) + client_class.assert_called_once_with("__Secure-1PSID=psid; __Secure-1PSIDTS=old-psidts", None, account_proxy="") client.rotate_psidts.assert_called_once_with() client.bootstrap_session_token.assert_called_once_with() refreshed_accounts = service.list_accounts(provider=GEMINI_PROVIDER) diff --git a/test/test_grok_client_parity.py b/test/test_grok_client_parity.py index a317537..73b7e3f 100644 --- a/test/test_grok_client_parity.py +++ b/test/test_grok_client_parity.py @@ -410,12 +410,13 @@ def test_app_chat_completion_refreshes_once_before_no_account_failure(self) -> N def test_console_chat_completion_marks_console_used_on_success(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="console-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) spec = resolve_model("grok-4.20-non-reasoning") class FakeConsoleClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account: Any = None) -> None: self.access_token = access_token def __enter__(self) -> "FakeConsoleClient": @@ -439,12 +440,13 @@ def create_response(self, payload: dict[str, Any]) -> dict[str, Any]: def test_console_chat_completion_events_marks_console_used_on_success(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="console-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) spec = resolve_model("grok-4.20-non-reasoning") class FakeConsoleClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account: Any = None) -> None: self.access_token = access_token def __enter__(self) -> "FakeConsoleClient": diff --git a/test/test_grok_provider.py b/test/test_grok_provider.py index 640dcd3..dd169a6 100644 --- a/test/test_grok_provider.py +++ b/test/test_grok_provider.py @@ -1003,6 +1003,7 @@ def test_non_streaming_grok_app_chat_completion_includes_reasoning_content(self) def test_console_chat_completion_uses_reserved_console_quota(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="grok-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) response_json = { @@ -1026,6 +1027,7 @@ def test_console_chat_completion_uses_reserved_console_quota(self) -> None: def test_console_chat_completion_marks_failed_request_without_extra_quota_decrement(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="grok-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) client = mock.Mock() @@ -1046,6 +1048,7 @@ def test_console_chat_completion_marks_failed_request_without_extra_quota_decrem def test_console_chat_completion_marks_empty_response_failed_without_extra_quota_decrement(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="grok-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) client = mock.Mock() @@ -1068,6 +1071,7 @@ def test_console_chat_completion_marks_empty_response_failed_without_extra_quota def test_console_chat_completion_validates_payload_before_reserving_quota(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="grok-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) @@ -1081,6 +1085,7 @@ def test_console_chat_completion_validates_payload_before_reserving_quota(self) def test_console_stream_uses_reserved_console_quota(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="grok-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) client = mock.Mock() @@ -2167,12 +2172,13 @@ def close(self) -> None: def test_grok_console_stream_does_not_mark_reserved_quota_when_generator_is_closed(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="selected-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) spec = resolve_model("grok-4.3") class FakeClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account=None) -> None: self.access_token = access_token def __enter__(self) -> "FakeClient": @@ -2202,12 +2208,13 @@ def stream_response(self, payload): def test_grok_console_stream_does_not_mark_reserved_quota_when_stream_completes_without_events(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="selected-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) spec = resolve_model("grok-4.3") class FakeClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account=None) -> None: self.access_token = access_token def __enter__(self) -> "FakeClient": @@ -2235,12 +2242,13 @@ def stream_response(self, payload): def test_grok_console_stream_marks_account_used_after_partial_stream_error(self) -> None: account_service = types.SimpleNamespace( get_grok_console_access_token=mock.Mock(return_value="selected-token"), + get_account=mock.Mock(return_value={}), mark_grok_console_used=mock.Mock(), ) spec = resolve_model("grok-4.3") class FakeClient: - def __init__(self, access_token: str) -> None: + def __init__(self, access_token: str, account=None) -> None: self.access_token = access_token def __enter__(self) -> "FakeClient": diff --git a/test/test_provider_models.py b/test/test_provider_models.py index 104b85f..043f49b 100644 --- a/test/test_provider_models.py +++ b/test/test_provider_models.py @@ -37,7 +37,7 @@ class FakeBackend: fail_authenticated = False fail_anonymous = False - def __init__(self, access_token: str = "") -> None: + def __init__(self, access_token: str = "", **kwargs) -> None: self.access_token = access_token self.__class__.calls.append(access_token) @@ -160,6 +160,7 @@ def test_list_models_tries_gpt_account_token_before_anonymous(self) -> None: account_service = FakeAccountService("stored-token") with mock.patch.object(openai_v1_models, "_get_gpt_access_token", account_service.get_text_access_token), \ + mock.patch.object(openai_v1_models, "_get_account_proxy", return_value=""), \ mock.patch.dict(sys.modules, {"services.openai_backend_api": types.SimpleNamespace( OpenAIBackendAPI=FakeBackend, )}): @@ -180,6 +181,7 @@ def test_list_models_falls_back_to_anonymous_when_account_fetch_fails(self) -> N FakeBackend.fail_authenticated = True with mock.patch.object(openai_v1_models, "_get_gpt_access_token", account_service.get_text_access_token), \ + mock.patch.object(openai_v1_models, "_get_account_proxy", return_value=""), \ mock.patch.dict(sys.modules, {"services.openai_backend_api": types.SimpleNamespace( OpenAIBackendAPI=FakeBackend, )}): @@ -199,6 +201,7 @@ def test_list_models_uses_fallbacks_when_authenticated_and_anonymous_fetch_fail( FakeBackend.fail_anonymous = True with mock.patch.object(openai_v1_models, "_get_gpt_access_token", account_service.get_text_access_token), \ + mock.patch.object(openai_v1_models, "_get_account_proxy", return_value=""), \ mock.patch.dict(sys.modules, {"services.openai_backend_api": types.SimpleNamespace( OpenAIBackendAPI=FakeBackend, )}): diff --git a/test/test_remote_account_api.py b/test/test_remote_account_api.py index 18c89e5..66f4397 100644 --- a/test/test_remote_account_api.py +++ b/test/test_remote_account_api.py @@ -51,6 +51,12 @@ def load_settings(self) -> dict[str, Any]: def save_settings(self, settings: dict[str, Any]) -> None: pass + def load_proxy_pool(self) -> list[dict[str, Any]]: + return [] + + def save_proxy_pool(self, items: list[dict[str, Any]]) -> None: + pass + def health_check(self) -> dict[str, Any]: return {"ok": True} diff --git a/test/test_turnstile_retry.py b/test/test_turnstile_retry.py index 12bf818..d8ebf8a 100644 --- a/test/test_turnstile_retry.py +++ b/test/test_turnstile_retry.py @@ -23,7 +23,7 @@ def test_retryable_turnstile_error_rotates_to_next_account_without_removing_old_ created_tokens: list[str] = [] class FakeBackend: - def __init__(self, access_token: str = "") -> None: + def __init__(self, access_token: str = "", **kwargs) -> None: self.access_token = access_token created_tokens.append(access_token) @@ -53,6 +53,7 @@ def get_text_access_token(attempted_tokens: set[str]) -> str: mock.patch.object(gpt_runtime, "OpenAIBackendAPI", FakeBackend), mock.patch.object(gpt_runtime, "conversation_events", fake_conversation_events), mock.patch.object(gpt_runtime, "account_service", account_service), + mock.patch.object(gpt_runtime, "_account_proxy", return_value=""), ): deltas = list(conversation.stream_text_deltas(initial_backend, request)) diff --git a/web/src/app/settings/components/proxy-pool-card.tsx b/web/src/app/settings/components/proxy-pool-card.tsx new file mode 100644 index 0000000..dcd96c7 --- /dev/null +++ b/web/src/app/settings/components/proxy-pool-card.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + LoaderCircle, + Network, + Plus, + Shuffle, + Trash2, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { + assignProxyPool, + clearProxyPoolAssignments, + deleteProxyPool, + fetchProxyPool, + importProxyPool, + type ProxyPoolItem, +} from "@/lib/api"; + +export function ProxyPoolCard() { + const didLoadRef = useRef(false); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isImporting, setIsImporting] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [isClearing, setIsClearing] = useState(false); + const [showImport, setShowImport] = useState(false); + const [importText, setImportText] = useState(""); + + const load = async () => { + setIsLoading(true); + try { + const data = await fetchProxyPool(); + setItems(data.items); + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载代理池失败"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (didLoadRef.current) return; + didLoadRef.current = true; + void load(); + }, []); + + const handleImport = async () => { + const text = importText.trim(); + if (!text) { + toast.error("请输入代理列表"); + return; + } + setIsImporting(true); + try { + const data = await importProxyPool(text); + setItems(data.items); + setImportText(""); + setShowImport(false); + toast.success(`导入完成:新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导入失败"); + } finally { + setIsImporting(false); + } + }; + + const handleDelete = async (id: string) => { + try { + const data = await deleteProxyPool([id]); + setItems(data.items); + toast.success("已删除"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败"); + } + }; + + const handleClearAll = async () => { + try { + const data = await deleteProxyPool([]); + setItems(data.items); + toast.success(`已清空代理池(删除 ${data.removed ?? 0} 个)`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "清空失败"); + } + }; + + const handleAssign = async () => { + setIsAssigning(true); + try { + const data = await assignProxyPool(); + if (data.error) { + toast.error(data.error); + } else { + toast.success(`已分配 ${data.assigned} 个账号(${data.total_proxies} 个代理轮转分配给 ${data.total_accounts} 个账号)`); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "分配失败"); + } finally { + setIsAssigning(false); + } + }; + + const handleClearAssignments = async () => { + setIsClearing(true); + try { + const data = await clearProxyPoolAssignments(); + toast.success(`已清除 ${data.cleared} 个账号的代理分配`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "清除失败"); + } finally { + setIsClearing(false); + } + }; + + return ( + + +
+
+
+ +
+
+

代理池

+

+ 批量导入代理 IP 并自动分配给账号,每个账号使用独立出口。 +

+
+
+ 0 ? "success" : "secondary"} className="w-fit rounded-md px-2.5 py-1"> + {items.length > 0 ? `${items.length} 个代理` : "未配置"} + +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Action buttons */} +
+ + + + {items.length > 0 && ( + + )} +
+ + {/* Import area */} + {showImport && ( +
+ +