From 0ee2ee95546e8761e39fb869aa58127a3a620532 Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:14:24 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(proxy):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9AAPI=E7=AB=AF=E7=82=B9=E9=85=8D=E7=BD=AE=E4=B8=8E429?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在代理配置中引入 `api_endpoints` 列表,支持配置多个上游API端点和密钥 - 当上游返回429(请求过多)状态码时,自动切换到列表中的下一个可用端点 - 修改 `ProxyConfig` 结构,将单 `api_url` 和 `api_key` 替换为 `api_endpoints` 元组 - 在 `ProxyApp` 中实现端点轮询逻辑,并在请求失败时更新游标 - 优化 `user_data_service.py` 中的 `max` 函数调用,使用 `os.path.basename` 直接作为 key - 确保在HTTP错误和请求结束时正确关闭响应连接,避免资源泄漏 --- python-src/modules/proxy/proxy_app.py | 102 +++++++++++++----- python-src/modules/proxy/proxy_config.py | 53 ++++++++- .../modules/services/user_data_service.py | 2 +- 3 files changed, 126 insertions(+), 31 deletions(-) diff --git a/python-src/modules/proxy/proxy_app.py b/python-src/modules/proxy/proxy_app.py index 8910444..956ed32 100644 --- a/python-src/modules/proxy/proxy_app.py +++ b/python-src/modules/proxy/proxy_app.py @@ -13,13 +13,20 @@ from flask import Flask, Response, jsonify, request from modules.proxy.proxy_auth import ProxyAuth -from modules.proxy.proxy_config import DEFAULT_MIDDLE_ROUTE, ProxyConfig, build_proxy_config +from modules.proxy.proxy_config import ( + DEFAULT_MIDDLE_ROUTE, + ProxyApiEndpoint, + ProxyConfig, + build_proxy_config, +) from modules.proxy.proxy_transport import ProxyTransport from modules.runtime.error_codes import ErrorCode from modules.runtime.operation_result import OperationResult from modules.runtime.resource_manager import ResourceManager from modules.services.system_prompt_service import SystemPromptStore +HTTP_STATUS_TOO_MANY_REQUESTS = 429 + class ProxyApp: """代理服务的领域逻辑:配置解析 + Flask 路由 + 上游转发。""" @@ -39,6 +46,7 @@ def __init__( self._transport_ref_counts: dict[int, int] = {} self._root_logger_default_level = logging.getLogger().level self._app_logger_default_level = logging.WARNING + self._endpoint_cursor = 0 self.app: Flask | None = None self.valid = True self.proxy_config: ProxyConfig | None = None @@ -111,6 +119,7 @@ def _snapshot_runtime_state(self) -> dict[str, Any]: "transport": self.transport, "http_client": self.http_client, "proxy_config": self.proxy_config, + "endpoint_cursor": self._endpoint_cursor, } def _snapshot_chat_runtime_state(self) -> dict[str, Any]: @@ -133,6 +142,7 @@ def _snapshot_chat_runtime_state(self) -> dict[str, Any]: "transport": transport, "http_client": self.http_client, "proxy_config": self.proxy_config, + "endpoint_cursor": self._endpoint_cursor, } def _release_transport_ref(self, transport: ProxyTransport | None) -> None: @@ -210,6 +220,7 @@ def apply_runtime_config(self, raw_config: dict[str, Any] | None) -> OperationRe self.auth = new_auth self.transport = new_transport self.http_client = new_transport.session + self._endpoint_cursor = 0 self._apply_debug_logging(self.debug_mode) @@ -227,6 +238,10 @@ def _timestamp_ms() -> str: ms = int((now % 1) * 1000) return f"{base}.{ms:03d}" + def _set_endpoint_cursor(self, value: int) -> None: + with self._config_lock: + self._endpoint_cursor = value + def _log_request(self, request_id: str, message: str) -> None: self.log_func(f"{self._timestamp_ms()} [{request_id}] {message}") @@ -452,6 +467,7 @@ def log(message: str) -> None: transport = snapshot["transport"] http_client = snapshot["http_client"] proxy_config = snapshot["proxy_config"] + endpoint_cursor = int(snapshot["endpoint_cursor"] or 0) transport_released = False def release_transport() -> None: @@ -533,36 +549,65 @@ def release_transport() -> None: {"error": {"message": "Invalid authentication", "type": "authentication_error"}} ), 401 - target_api_key = "" - if isinstance(proxy_config, ProxyConfig): - target_api_key = proxy_config.api_key - forward_headers = auth.build_forward_headers( - auth_header, - target_api_key, - log_func=log, - ) - try: - target_url = ( - f"{target_api_base_url.rstrip('/')}" - f"{self._build_route(middle_route, 'chat/completions')}" - ) - log(f"转发请求到: {target_url}") - is_stream = request_data.get("stream", False) log(f"流模式: {is_stream}") - response_from_target = http_client.post( - target_url, - json=request_data, - headers=forward_headers, - stream=is_stream, - timeout=300, - ) - response_from_target.raise_for_status() - if debug_mode: - log(f"上游响应状态码: {response_from_target.status_code}") - log(f"上游 Content-Type: {response_from_target.headers.get('content-type')}") + api_endpoints: tuple[ProxyApiEndpoint, ...] + if isinstance(proxy_config, ProxyConfig): + api_endpoints = proxy_config.api_endpoints + else: + api_endpoints = (ProxyApiEndpoint(api_url=target_api_base_url, api_key=""),) + + start_index = endpoint_cursor % len(api_endpoints) + response_from_target = None + for attempt in range(len(api_endpoints)): + endpoint_index = (start_index + attempt) % len(api_endpoints) + endpoint = api_endpoints[endpoint_index] + target_url = ( + f"{endpoint.api_url.rstrip('/')}" + f"{self._build_route(middle_route, 'chat/completions')}" + ) + log(f"转发请求到: {target_url}") + + target_api_key = endpoint.api_key + if not target_api_key and isinstance(proxy_config, ProxyConfig): + target_api_key = proxy_config.api_key + forward_headers = auth.build_forward_headers( + auth_header, + target_api_key, + log_func=log, + ) + + response_from_target = http_client.post( + target_url, + json=request_data, + headers=forward_headers, + stream=is_stream, + timeout=300, + ) + + if ( + response_from_target.status_code == HTTP_STATUS_TOO_MANY_REQUESTS + and attempt < len(api_endpoints) - 1 + ): + next_index = (endpoint_index + 1) % len(api_endpoints) + log(f"上游触发 429,切换节点 {endpoint_index} -> {next_index}") + self._set_endpoint_cursor(next_index) + with contextlib.suppress(Exception): + response_from_target.close() + response_from_target = None + continue + + response_from_target.raise_for_status() + self._set_endpoint_cursor(endpoint_index) + if debug_mode: + log(f"上游响应状态码: {response_from_target.status_code}") + log(f"上游 Content-Type: {response_from_target.headers.get('content-type')}") + break + + if response_from_target is None: + raise requests.exceptions.RequestException("No available target API endpoint") if is_stream: log("返回流式响应") @@ -749,6 +794,9 @@ def simulate_stream() -> Generator[str]: except requests.exceptions.HTTPError as e: error_msg = f"目标 API HTTP 错误: {e.response.status_code} - {e.response.text}" log(error_msg) + with contextlib.suppress(Exception): + if e.response is not None: + e.response.close() release_transport() return jsonify( {"error": f"Target API error: {e.response.status_code}", "details": e.response.text} diff --git a/python-src/modules/proxy/proxy_config.py b/python-src/modules/proxy/proxy_config.py index 4e9e81a..2c15071 100644 --- a/python-src/modules/proxy/proxy_config.py +++ b/python-src/modules/proxy/proxy_config.py @@ -3,7 +3,7 @@ import os from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast import yaml @@ -14,9 +14,16 @@ type LogFunc = Callable[[str], None] +@dataclass(frozen=True) +class ProxyApiEndpoint: + api_url: str + api_key: str + + @dataclass(frozen=True) class ProxyConfig: target_api_base_url: str + api_endpoints: tuple[ProxyApiEndpoint, ...] middle_route: str custom_model_id: str target_model_id: str @@ -53,6 +60,43 @@ def _resolve_target_model_id(*, raw_config: dict[str, Any], custom_model_id: str return target_model_id if target_model_id else custom_model_id +def _parse_api_endpoints(*, raw_config: dict[str, Any]) -> tuple[ProxyApiEndpoint, ...]: + raw_list = raw_config.get("api_endpoints") + if isinstance(raw_list, list): + raw_items = cast(list[Any], raw_list) + endpoints: list[ProxyApiEndpoint] = [] + for item_any in raw_items: + if not isinstance(item_any, dict): + continue + item = cast(dict[str, Any], item_any) + url_value = item.get("api_url") or item.get("url") or "" + if not isinstance(url_value, str): + url_value = "" + api_url = url_value.strip() + if not api_url: + continue + key_value = item.get("api_key") or item.get("key") or "" + if not isinstance(key_value, str): + key_value = "" + api_key = key_value.strip() + endpoints.append(ProxyApiEndpoint(api_url=api_url, api_key=api_key)) + if endpoints: + return tuple(endpoints) + + api_url_value = raw_config.get("api_url", PLACEHOLDER_API_URL) + if not isinstance(api_url_value, str): + api_url_value = PLACEHOLDER_API_URL + api_key_value = raw_config.get("api_key") or "" + if not isinstance(api_key_value, str): + api_key_value = "" + return ( + ProxyApiEndpoint( + api_url=api_url_value, + api_key=api_key_value, + ), + ) + + def normalize_middle_route(value: str | None) -> str: raw_value = (value or "").strip() if not raw_value: @@ -75,7 +119,8 @@ def build_proxy_config( raw_config = raw_config or {} global_config = load_global_config(resource_manager=resource_manager, log_func=log_func) - target_api_base_url = raw_config.get("api_url", PLACEHOLDER_API_URL) + api_endpoints = _parse_api_endpoints(raw_config=raw_config) + target_api_base_url = api_endpoints[0].api_url if target_api_base_url == PLACEHOLDER_API_URL: log_func("错误: 请在配置中设置正确的 API URL") return None @@ -92,19 +137,21 @@ def build_proxy_config( return ProxyConfig( target_api_base_url=target_api_base_url, + api_endpoints=api_endpoints, middle_route=middle_route, custom_model_id=custom_model_id, target_model_id=target_model_id, stream_mode=raw_config.get("stream_mode"), debug_mode=bool(raw_config.get("debug_mode", False)), disable_ssl_strict_mode=bool(raw_config.get("disable_ssl_strict_mode", False)), - api_key=(raw_config.get("api_key") or ""), + api_key=api_endpoints[0].api_key, mtga_auth_key=(global_config.get("mtga_auth_key") or ""), ) __all__ = [ "DEFAULT_MIDDLE_ROUTE", + "ProxyApiEndpoint", "ProxyConfig", "PLACEHOLDER_API_URL", "build_proxy_config", diff --git a/python-src/modules/services/user_data_service.py b/python-src/modules/services/user_data_service.py index dd16f4b..42b749b 100644 --- a/python-src/modules/services/user_data_service.py +++ b/python-src/modules/services/user_data_service.py @@ -126,7 +126,7 @@ def find_latest_backup( if not backup_folders: raise NoBackupsError("未找到任何备份") - latest_backup = max(backup_folders, key=lambda x: os.path.basename(x)) + latest_backup = max(backup_folders, key=os.path.basename) backup_name = os.path.basename(latest_backup) return LatestBackupInfo(backup_name=backup_name, backup_path=latest_backup) From e2b8697a1f6494a5ebb03bf4485dcc30bfa93788 Mon Sep 17 00:00:00 2001 From: QuickLAW <1441919610@qq.com> Date: Wed, 11 Mar 2026 21:58:45 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(proxy):=20=E6=94=B9=E8=BF=9B=20429=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=97=A5=E5=BF=97=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在收到上游 429 状态码时,现在会检查并记录 `retry-after` 头信息,以便于调试。同时,将节点切换逻辑移至 429 条件块内部,使逻辑更清晰,避免不必要的节点切换。 --- python-src/modules/proxy/proxy_app.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/python-src/modules/proxy/proxy_app.py b/python-src/modules/proxy/proxy_app.py index 956ed32..47f10af 100644 --- a/python-src/modules/proxy/proxy_app.py +++ b/python-src/modules/proxy/proxy_app.py @@ -587,17 +587,21 @@ def release_transport() -> None: timeout=300, ) - if ( - response_from_target.status_code == HTTP_STATUS_TOO_MANY_REQUESTS - and attempt < len(api_endpoints) - 1 - ): - next_index = (endpoint_index + 1) % len(api_endpoints) - log(f"上游触发 429,切换节点 {endpoint_index} -> {next_index}") - self._set_endpoint_cursor(next_index) - with contextlib.suppress(Exception): - response_from_target.close() - response_from_target = None - continue + if response_from_target.status_code == HTTP_STATUS_TOO_MANY_REQUESTS: + retry_after = response_from_target.headers.get("retry-after") + retry_after_text = retry_after if retry_after else "-" + log( + "上游触发 429" + f"(节点={endpoint_index},总节点={len(api_endpoints)},retry-after={retry_after_text})" + ) + if attempt < len(api_endpoints) - 1: + next_index = (endpoint_index + 1) % len(api_endpoints) + log(f"切换到下一个节点 {endpoint_index} -> {next_index}") + self._set_endpoint_cursor(next_index) + with contextlib.suppress(Exception): + response_from_target.close() + response_from_target = None + continue response_from_target.raise_for_status() self._set_endpoint_cursor(endpoint_index) From a1ba2eb5eb0ba2c490a1092038a9e382c0a8b4ef Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:40:01 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat(proxy):=20=E5=AE=9E=E7=8E=B0429?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=87=AA=E5=8A=A8=E6=95=85=E9=9A=9C=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB=E4=B8=8E=E7=A1=85=E5=9F=BA=E6=B5=81=E5=8A=A8=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增端点级429冷却机制,通过 `_endpoint_429_until` 字典记录冷却时间 - 当启用 `enable_429_failover` 且配置多个端点时,自动跳过冷却中的节点 - 适配 SiliconFlow 的 thinking 参数,将其转换为 enable_thinking 和 thinking_budget - 在单端点或未启用故障转移时保持原有轮询逻辑 - 添加400错误时的请求参数日志以便调试 --- python-src/modules/proxy/proxy_app.py | 110 +++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/python-src/modules/proxy/proxy_app.py b/python-src/modules/proxy/proxy_app.py index 47f10af..c281702 100644 --- a/python-src/modules/proxy/proxy_app.py +++ b/python-src/modules/proxy/proxy_app.py @@ -47,6 +47,7 @@ def __init__( self._root_logger_default_level = logging.getLogger().level self._app_logger_default_level = logging.WARNING self._endpoint_cursor = 0 + self._endpoint_429_until: dict[str, float] = {} self.app: Flask | None = None self.valid = True self.proxy_config: ProxyConfig | None = None @@ -557,17 +558,95 @@ def release_transport() -> None: if isinstance(proxy_config, ProxyConfig): api_endpoints = proxy_config.api_endpoints else: - api_endpoints = (ProxyApiEndpoint(api_url=target_api_base_url, api_key=""),) + api_endpoints = ( + ProxyApiEndpoint( + api_url=target_api_base_url, + api_key="", + target_model_id=target_model_id, + ), + ) + + enable_routing = ( + isinstance(proxy_config, ProxyConfig) + and bool(proxy_config.enable_429_failover) + and len(api_endpoints) > 1 + ) + if enable_routing: + start_index = (endpoint_cursor + 1) % len(api_endpoints) + else: + start_index = endpoint_cursor % len(api_endpoints) - start_index = endpoint_cursor % len(api_endpoints) response_from_target = None - for attempt in range(len(api_endpoints)): - endpoint_index = (start_index + attempt) % len(api_endpoints) + endpoint_order = [ + (start_index + offset) % len(api_endpoints) for offset in range(len(api_endpoints)) + ] + + def endpoint_key(endpoint: ProxyApiEndpoint) -> str: + return f"{endpoint.api_url}|{endpoint.api_key}|{endpoint.target_model_id}" + + def cooldown_remaining_seconds(key: str) -> float: + now = time.monotonic() + with self._config_lock: + until = float(self._endpoint_429_until.get(key, 0.0)) + if until <= now: + self._endpoint_429_until.pop(key, None) + return 0.0 + return until - now + + if enable_routing: + available = [] + for idx in endpoint_order: + remaining = cooldown_remaining_seconds(endpoint_key(api_endpoints[idx])) + if remaining <= 0: + available.append(idx) + if available: + endpoint_order = available + else: + min_idx = endpoint_order[0] + min_remaining = cooldown_remaining_seconds( + endpoint_key(api_endpoints[min_idx]) + ) + for idx in endpoint_order[1:]: + remaining = cooldown_remaining_seconds(endpoint_key(api_endpoints[idx])) + if remaining < min_remaining: + min_idx = idx + min_remaining = remaining + endpoint_order = [min_idx] + log( + "所有节点处于 429 冷却中" + f"(节点={min_idx},剩余={min_remaining:.1f}s)" + ) + + for attempt, endpoint_index in enumerate(endpoint_order): endpoint = api_endpoints[endpoint_index] target_url = ( f"{endpoint.api_url.rstrip('/')}" f"{self._build_route(middle_route, 'chat/completions')}" ) + + if endpoint.target_model_id: + request_data["model"] = endpoint.target_model_id + + current_request_data = dict(request_data) + + if "siliconflow.cn" in target_url or "siliconflow.com" in target_url: + thinking_obj = current_request_data.get("thinking") + if thinking_obj is not None: + log(f"适配 SiliconFlow 参数,thinking={json.dumps(thinking_obj)}") + if isinstance(thinking_obj, dict): + t_type = thinking_obj.get("type") + t_budget = ( + thinking_obj.get("budget_tokens") + or thinking_obj.get("budget") + ) + if isinstance(t_type, str) and t_type: + current_request_data["enable_thinking"] = t_type != "disabled" + if isinstance(t_budget, (int, float)): + current_request_data["thinking_budget"] = int(t_budget) + elif isinstance(thinking_obj, str): + current_request_data["enable_thinking"] = thinking_obj != "disabled" + current_request_data.pop("thinking", None) + log(f"转发请求到: {target_url}") target_api_key = endpoint.api_key @@ -581,23 +660,31 @@ def release_transport() -> None: response_from_target = http_client.post( target_url, - json=request_data, + json=current_request_data, headers=forward_headers, stream=is_stream, timeout=300, ) if response_from_target.status_code == HTTP_STATUS_TOO_MANY_REQUESTS: + retry_after_seconds: float | None = None retry_after = response_from_target.headers.get("retry-after") retry_after_text = retry_after if retry_after else "-" + if retry_after and retry_after.isdigit(): + retry_after_seconds = float(int(retry_after)) + if retry_after_seconds is None: + retry_after_seconds = 10.0 + key = endpoint_key(endpoint) + with self._config_lock: + self._endpoint_429_until[key] = time.monotonic() + retry_after_seconds log( "上游触发 429" f"(节点={endpoint_index},总节点={len(api_endpoints)},retry-after={retry_after_text})" ) - if attempt < len(api_endpoints) - 1: - next_index = (endpoint_index + 1) % len(api_endpoints) - log(f"切换到下一个节点 {endpoint_index} -> {next_index}") - self._set_endpoint_cursor(next_index) + if enable_routing: + self._set_endpoint_cursor(endpoint_index) + if enable_routing and attempt < len(endpoint_order) - 1: + log(f"切换到下一个节点 {endpoint_index} -> {endpoint_order[attempt + 1]}") with contextlib.suppress(Exception): response_from_target.close() response_from_target = None @@ -605,6 +692,8 @@ def release_transport() -> None: response_from_target.raise_for_status() self._set_endpoint_cursor(endpoint_index) + with self._config_lock: + self._endpoint_429_until.pop(endpoint_key(endpoint), None) if debug_mode: log(f"上游响应状态码: {response_from_target.status_code}") log(f"上游 Content-Type: {response_from_target.headers.get('content-type')}") @@ -798,6 +887,9 @@ def simulate_stream() -> Generator[str]: except requests.exceptions.HTTPError as e: error_msg = f"目标 API HTTP 错误: {e.response.status_code} - {e.response.text}" log(error_msg) + if e.response.status_code == 400: + with contextlib.suppress(Exception): + log(f"--- 触发 400 错误的请求参数 ---\\n{json.dumps(request_data, indent=2, ensure_ascii=False)}") with contextlib.suppress(Exception): if e.response is not None: e.response.close() From cb9f2dac3de0124b3b83625b3658e2fd33facdee Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:40:23 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(proxy):=20=E4=B8=BAAPI=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E6=B7=BB=E5=8A=A0=E7=9B=AE=E6=A0=87=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?ID=E5=B9=B6=E6=94=AF=E6=8C=81429=E6=95=85=E9=9A=9C=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展ProxyApiEndpoint类以包含target_model_id字段,并新增enable_429_failover配置选项。重构_parse_api_endpoints函数,使其支持从全局配置的config_groups中读取故障转移端点列表,并在启用故障转移时进行去重处理。调整build_proxy_config中的调用顺序以适配新的参数传递。 --- python-src/modules/proxy/proxy_config.py | 102 +++++++++++++++-------- 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/python-src/modules/proxy/proxy_config.py b/python-src/modules/proxy/proxy_config.py index 2c15071..9c2e798 100644 --- a/python-src/modules/proxy/proxy_config.py +++ b/python-src/modules/proxy/proxy_config.py @@ -18,6 +18,7 @@ class ProxyApiEndpoint: api_url: str api_key: str + target_model_id: str @dataclass(frozen=True) @@ -32,6 +33,7 @@ class ProxyConfig: disable_ssl_strict_mode: bool api_key: str mtga_auth_key: str + enable_429_failover: bool def load_global_config( @@ -60,41 +62,69 @@ def _resolve_target_model_id(*, raw_config: dict[str, Any], custom_model_id: str return target_model_id if target_model_id else custom_model_id -def _parse_api_endpoints(*, raw_config: dict[str, Any]) -> tuple[ProxyApiEndpoint, ...]: - raw_list = raw_config.get("api_endpoints") - if isinstance(raw_list, list): - raw_items = cast(list[Any], raw_list) - endpoints: list[ProxyApiEndpoint] = [] - for item_any in raw_items: - if not isinstance(item_any, dict): - continue - item = cast(dict[str, Any], item_any) - url_value = item.get("api_url") or item.get("url") or "" - if not isinstance(url_value, str): - url_value = "" - api_url = url_value.strip() - if not api_url: - continue - key_value = item.get("api_key") or item.get("key") or "" - if not isinstance(key_value, str): - key_value = "" - api_key = key_value.strip() - endpoints.append(ProxyApiEndpoint(api_url=api_url, api_key=api_key)) - if endpoints: - return tuple(endpoints) - +def _parse_api_endpoints( + *, + raw_config: dict[str, Any], + global_config: dict[str, Any], + custom_model_id: str, +) -> tuple[ProxyApiEndpoint, ...]: + enable_failover = bool(global_config.get("enable_429_failover", False)) + endpoints: list[ProxyApiEndpoint] = [] + + # 1. Primary endpoint (from raw_config) api_url_value = raw_config.get("api_url", PLACEHOLDER_API_URL) if not isinstance(api_url_value, str): api_url_value = PLACEHOLDER_API_URL api_key_value = raw_config.get("api_key") or "" if not isinstance(api_key_value, str): api_key_value = "" - return ( - ProxyApiEndpoint( - api_url=api_url_value, - api_key=api_key_value, - ), + target_model_id = (raw_config.get("model_id") or "").strip() + if not target_model_id: + target_model_id = custom_model_id + + primary_endpoint = ProxyApiEndpoint( + api_url=api_url_value, + api_key=api_key_value, + target_model_id=target_model_id, ) + endpoints.append(primary_endpoint) + + # 2. Failover endpoints + if enable_failover: + raw_groups = global_config.get("config_groups") + if isinstance(raw_groups, list): + config_groups = cast(list[Any], raw_groups) + for group_any in config_groups: + if not isinstance(group_any, dict): + continue + group = cast(dict[str, Any], group_any) + + url = (group.get("api_url") or "").strip() + if not url or url == PLACEHOLDER_API_URL: + continue + + key = (group.get("api_key") or "").strip() + model = (group.get("model_id") or "").strip() + if not model: + model = custom_model_id + + # Deduplicate + if ( + url == api_url_value + and key == api_key_value + and model == target_model_id + ): + continue + + endpoints.append( + ProxyApiEndpoint( + api_url=url, + api_key=key, + target_model_id=model, + ) + ) + + return tuple(endpoints) def normalize_middle_route(value: str | None) -> str: @@ -119,16 +149,21 @@ def build_proxy_config( raw_config = raw_config or {} global_config = load_global_config(resource_manager=resource_manager, log_func=log_func) - api_endpoints = _parse_api_endpoints(raw_config=raw_config) + custom_model_id = _resolve_custom_model_id( + global_config=global_config, + raw_config=raw_config, + ) + + api_endpoints = _parse_api_endpoints( + raw_config=raw_config, + global_config=global_config, + custom_model_id=custom_model_id, + ) target_api_base_url = api_endpoints[0].api_url if target_api_base_url == PLACEHOLDER_API_URL: log_func("错误: 请在配置中设置正确的 API URL") return None - custom_model_id = _resolve_custom_model_id( - global_config=global_config, - raw_config=raw_config, - ) target_model_id = _resolve_target_model_id( raw_config=raw_config, custom_model_id=custom_model_id, @@ -146,6 +181,7 @@ def build_proxy_config( disable_ssl_strict_mode=bool(raw_config.get("disable_ssl_strict_mode", False)), api_key=api_endpoints[0].api_key, mtga_auth_key=(global_config.get("mtga_auth_key") or ""), + enable_429_failover=bool(global_config.get("enable_429_failover", False)), ) From ab440c96f8cb13408491724c8361ddee194d6a84 Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:40:38 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(config=5Fservice):=20=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE=E6=B7=BB=E5=8A=A0429?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E8=BD=AC=E7=A7=BB=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在load_global_config和save_config_groups方法中新增enable_429_failover布尔字段,以支持在遇到HTTP 429状态码时启用故障转移逻辑。 --- python-src/modules/services/config_service.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python-src/modules/services/config_service.py b/python-src/modules/services/config_service.py index a5455f1..02d14ff 100644 --- a/python-src/modules/services/config_service.py +++ b/python-src/modules/services/config_service.py @@ -24,7 +24,7 @@ def load_config_groups(self) -> tuple[list[dict[str, Any]], int]: pass return [], 0 - def load_global_config(self) -> tuple[str, str]: + def load_global_config(self) -> tuple[str, str, bool]: try: if os.path.exists(self.config_file): with open(self.config_file, encoding="utf-8") as f: @@ -32,10 +32,11 @@ def load_global_config(self) -> tuple[str, str]: if config: mapped_model_id = config.get("mapped_model_id", "") mtga_auth_key = config.get("mtga_auth_key", "") - return mapped_model_id, mtga_auth_key + enable_429_failover = bool(config.get("enable_429_failover", False)) + return mapped_model_id, mtga_auth_key, enable_429_failover except Exception: pass - return "", "" + return "", "", False def save_config_groups( self, @@ -43,6 +44,7 @@ def save_config_groups( current_index: int = 0, mapped_model_id: str | None = None, mtga_auth_key: str | None = None, + enable_429_failover: bool | None = None, ) -> bool: try: config_data: dict[str, Any] = {} @@ -57,6 +59,8 @@ def save_config_groups( config_data["mapped_model_id"] = mapped_model_id if mtga_auth_key is not None: config_data["mtga_auth_key"] = mtga_auth_key + if enable_429_failover is not None: + config_data["enable_429_failover"] = enable_429_failover os.makedirs(os.path.dirname(self.config_file), exist_ok=True) From cc8438fca99c1256d1d72fbb76a25f6c2ed3f802 Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:40:57 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix(proxy=5Forchestration):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E7=9A=84=E6=96=B0=E8=BF=94=E5=9B=9E=E5=80=BC?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新函数调用以解构三元组,忽略新增的布尔标志位,保持现有逻辑不变。 --- python-src/modules/services/proxy_orchestration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python-src/modules/services/proxy_orchestration.py b/python-src/modules/services/proxy_orchestration.py index 821a028..e54f968 100644 --- a/python-src/modules/services/proxy_orchestration.py +++ b/python-src/modules/services/proxy_orchestration.py @@ -35,9 +35,9 @@ class GlobalConfigCheckResult: def ensure_global_config_ready( *, - load_global_config: Callable[[], tuple[str, str]], + load_global_config: Callable[[], tuple[str, str, bool]], ) -> GlobalConfigCheckResult: - mapped_model_id, mtga_auth_key = load_global_config() + mapped_model_id, mtga_auth_key, _ = load_global_config() mapped_model_id = (mapped_model_id or "").strip() mtga_auth_key = (mtga_auth_key or "").strip() From 62dfeb9817ddfa770bb5c1c3e629fbf2c152d48b Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:41:08 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20429=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=BD=AC=E7=A7=BB=E5=BC=80=E5=85=B3=E5=88=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 SaveConfigPayload 模型中新增 enable_429_failover 布尔字段,用于控制是否启用 429 状态码的失败转移机制。同时更新了 load_config 和 save_config 函数以支持该配置的加载和保存。 --- python-src/mtga_app/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python-src/mtga_app/__init__.py b/python-src/mtga_app/__init__.py index 7b1e014..feeb4ad 100644 --- a/python-src/mtga_app/__init__.py +++ b/python-src/mtga_app/__init__.py @@ -258,6 +258,7 @@ class SaveConfigPayload(BaseModel): current_config_index: int mapped_model_id: str | None = None mtga_auth_key: str | None = None + enable_429_failover: bool | None = None @lru_cache(maxsize=1) @@ -280,12 +281,13 @@ async def greet(body: GreetPayload) -> str: async def load_config() -> dict[str, Any]: config_store = _get_config_store() config_groups, current_index = config_store.load_config_groups() - mapped_model_id, mtga_auth_key = config_store.load_global_config() + mapped_model_id, mtga_auth_key, enable_429_failover = config_store.load_global_config() return { "config_groups": config_groups, "current_config_index": current_index, "mapped_model_id": mapped_model_id, "mtga_auth_key": mtga_auth_key, + "enable_429_failover": enable_429_failover, } @@ -297,6 +299,7 @@ async def save_config(body: SaveConfigPayload) -> bool: body.current_config_index, body.mapped_model_id, body.mtga_auth_key, + body.enable_429_failover, ) From a52533e097a2f98fbc471d83fedfabcb43d0365a Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:41:19 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat(=E9=85=8D=E7=BD=AE):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20API=20429=20=E6=95=85=E9=9A=9C=E8=BD=AC=E7=A7=BB?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在配置类型和状态管理中新增 `enable_429_failover` 选项,并在设置面板提供对应的 UI 开关。 启用后,请求将在配置组间轮询分发,并在遇到 429 状态码时自动切换到下一个节点,避免因请求限制导致服务中断。 --- .vscode/settings.json | 2 +- app/components/panels/SettingsPanel.vue | 36 +++++++++++++++++++++++++ app/composables/mtgaTypes.ts | 1 + app/composables/useMtgaStore.ts | 4 +++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d44549..d8fa722 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -65,7 +65,7 @@ "editor.defaultFormatter": "redhat.vscode-yaml", "editor.formatOnSave": true }, - "python.languageServer": "Pylance", + "python.languageServer": "None", "rust-analyzer.cargo.extraEnv": { "PYO3_PYTHON": "${workspaceFolder}/src-tauri/pyembed/python/python.exe" } diff --git a/app/components/panels/SettingsPanel.vue b/app/components/panels/SettingsPanel.vue index 16de15f..adf00f4 100644 --- a/app/components/panels/SettingsPanel.vue +++ b/app/components/panels/SettingsPanel.vue @@ -116,6 +116,22 @@ const openThemeDialog = () => { themeDialogOpen.value = true; }; +const enable429Failover = computed({ + get: () => store.enable429Failover.value, + set: (value) => { + store.enable429Failover.value = value; + }, +}); + +const handleFailoverChange = async () => { + const ok = await store.saveConfig(); + if (ok) { + store.appendLog(`API 智能调度已${enable429Failover.value ? "启用" : "禁用"}`); + } else { + store.appendLog("保存配置失败"); + } +}; + const handleThemeSave = (value: ThemeConfig) => { const normalized = sanitizeThemeConfig(value); copyThemeConfig(themeConfig, normalized); @@ -139,6 +155,26 @@ const handleThemeSave = (value: ThemeConfig) => {
+
+
+
高级策略
+
API 调度与故障转移
+
+ +
+ 开启后,请求会在配置组间轮询分发;当某个节点触发 429 (Too Many Requests) 时, + 将自动切换到下一个节点,并对 429 节点进行短暂冷却以避免重复命中。 +
+
+
用户数据
diff --git a/app/composables/mtgaTypes.ts b/app/composables/mtgaTypes.ts index 0c89382..7f053fe 100644 --- a/app/composables/mtgaTypes.ts +++ b/app/composables/mtgaTypes.ts @@ -13,6 +13,7 @@ export type ConfigPayload = { current_config_index: number; mapped_model_id: string; mtga_auth_key: string; + enable_429_failover?: boolean; }; export type AppInfo = { diff --git a/app/composables/useMtgaStore.ts b/app/composables/useMtgaStore.ts index f04d568..7d6e476 100644 --- a/app/composables/useMtgaStore.ts +++ b/app/composables/useMtgaStore.ts @@ -165,6 +165,7 @@ export const useMtgaStore = () => { const currentConfigIndex = useState("mtga-current-config-index", () => 0); const mappedModelId = useState("mtga-mapped-model-id", () => ""); const mtgaAuthKey = useState("mtga-auth-key", () => ""); + const enable429Failover = useState("mtga-enable-429-failover", () => false); const runtimeOptions = useState("mtga-runtime-options", () => ({ ...DEFAULT_RUNTIME_OPTIONS, })); @@ -452,6 +453,7 @@ export const useMtgaStore = () => { ); mappedModelId.value = coerceText(result.mapped_model_id); mtgaAuthKey.value = coerceText(result.mtga_auth_key); + enable429Failover.value = Boolean(result.enable_429_failover); return true; }; @@ -463,6 +465,7 @@ export const useMtgaStore = () => { current_config_index: clampedIndex, mapped_model_id: coerceText(mappedModelId.value), mtga_auth_key: coerceText(mtgaAuthKey.value), + enable_429_failover: enable429Failover.value, }; const ok = await api.saveConfig(payload); return Boolean(ok); @@ -786,6 +789,7 @@ export const useMtgaStore = () => { currentConfigIndex, mappedModelId, mtgaAuthKey, + enable429Failover, runtimeOptions, logs, systemPrompts, From 6adfaab3a5c079d7de7b6c2645369c8af43b6148 Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:30:45 +0800 Subject: [PATCH 09/13] =?UTF-8?q?feat(proxy):=20=E4=B8=BA429=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E8=BD=AC=E7=A7=BB=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=86=B7=E5=8D=B4=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在全局配置中添加 `failover_429_cooldown_seconds` 字段,允许用户自定义触发429后的节点冷却时长 - 前端设置面板增加对应数值输入控件,支持秒级调整 - 代理逻辑使用配置的冷却时间,替代之前硬编码的10秒 - 修复SSE事件解析以同时支持 `\n\n` 和 `\r\n\r\n` 分隔符 - 重构配置保存逻辑,使用字典统一处理全局配置更新 --- app/components/panels/SettingsPanel.vue | 17 ++++++++ app/composables/mtgaTypes.ts | 1 + app/composables/useMtgaStore.ts | 13 ++++++ python-src/modules/proxy/proxy_app.py | 22 ++++++---- python-src/modules/proxy/proxy_config.py | 4 ++ python-src/modules/proxy/proxy_transport.py | 15 +++++-- python-src/modules/services/config_service.py | 42 +++++++++++++------ .../modules/services/proxy_orchestration.py | 4 +- python-src/mtga_app/__init__.py | 20 +++++++-- 9 files changed, 108 insertions(+), 30 deletions(-) diff --git a/app/components/panels/SettingsPanel.vue b/app/components/panels/SettingsPanel.vue index adf00f4..c128883 100644 --- a/app/components/panels/SettingsPanel.vue +++ b/app/components/panels/SettingsPanel.vue @@ -123,6 +123,13 @@ const enable429Failover = computed({ }, }); +const failover429CooldownSeconds = computed({ + get: () => store.failover429CooldownSeconds.value, + set: (value) => { + store.failover429CooldownSeconds.value = value; + }, +}); + const handleFailoverChange = async () => { const ok = await store.saveConfig(); if (ok) { @@ -169,6 +176,16 @@ const handleThemeSave = (value: ThemeConfig) => { /> 启用 API 智能调度(轮询 + 429 切换) +
开启后,请求会在配置组间轮询分发;当某个节点触发 429 (Too Many Requests) 时, 将自动切换到下一个节点,并对 429 节点进行短暂冷却以避免重复命中。 diff --git a/app/composables/mtgaTypes.ts b/app/composables/mtgaTypes.ts index 7f053fe..663eca6 100644 --- a/app/composables/mtgaTypes.ts +++ b/app/composables/mtgaTypes.ts @@ -14,6 +14,7 @@ export type ConfigPayload = { mapped_model_id: string; mtga_auth_key: string; enable_429_failover?: boolean; + failover_429_cooldown_seconds?: number; }; export type AppInfo = { diff --git a/app/composables/useMtgaStore.ts b/app/composables/useMtgaStore.ts index 7d6e476..1e7fc96 100644 --- a/app/composables/useMtgaStore.ts +++ b/app/composables/useMtgaStore.ts @@ -166,6 +166,10 @@ export const useMtgaStore = () => { const mappedModelId = useState("mtga-mapped-model-id", () => ""); const mtgaAuthKey = useState("mtga-auth-key", () => ""); const enable429Failover = useState("mtga-enable-429-failover", () => false); + const failover429CooldownSeconds = useState( + "mtga-failover-429-cooldown-seconds", + () => 60, + ); const runtimeOptions = useState("mtga-runtime-options", () => ({ ...DEFAULT_RUNTIME_OPTIONS, })); @@ -454,6 +458,10 @@ export const useMtgaStore = () => { mappedModelId.value = coerceText(result.mapped_model_id); mtgaAuthKey.value = coerceText(result.mtga_auth_key); enable429Failover.value = Boolean(result.enable_429_failover); + const cooldownRaw = Number(result.failover_429_cooldown_seconds); + failover429CooldownSeconds.value = Number.isFinite(cooldownRaw) + ? Math.max(1, Math.round(cooldownRaw)) + : 60; return true; }; @@ -466,6 +474,10 @@ export const useMtgaStore = () => { mapped_model_id: coerceText(mappedModelId.value), mtga_auth_key: coerceText(mtgaAuthKey.value), enable_429_failover: enable429Failover.value, + failover_429_cooldown_seconds: Math.max( + 1, + Math.round(Number(failover429CooldownSeconds.value) || 60), + ), }; const ok = await api.saveConfig(payload); return Boolean(ok); @@ -790,6 +802,7 @@ export const useMtgaStore = () => { mappedModelId, mtgaAuthKey, enable429Failover, + failover429CooldownSeconds, runtimeOptions, logs, systemPrompts, diff --git a/python-src/modules/proxy/proxy_app.py b/python-src/modules/proxy/proxy_app.py index c281702..a8cbf7a 100644 --- a/python-src/modules/proxy/proxy_app.py +++ b/python-src/modules/proxy/proxy_app.py @@ -26,6 +26,7 @@ from modules.services.system_prompt_service import SystemPromptStore HTTP_STATUS_TOO_MANY_REQUESTS = 429 +HTTP_STATUS_BAD_REQUEST = 400 class ProxyApp: @@ -594,7 +595,7 @@ def cooldown_remaining_seconds(key: str) -> float: return until - now if enable_routing: - available = [] + available: list[int] = [] for idx in endpoint_order: remaining = cooldown_remaining_seconds(endpoint_key(api_endpoints[idx])) if remaining <= 0: @@ -634,10 +635,10 @@ def cooldown_remaining_seconds(key: str) -> float: if thinking_obj is not None: log(f"适配 SiliconFlow 参数,thinking={json.dumps(thinking_obj)}") if isinstance(thinking_obj, dict): - t_type = thinking_obj.get("type") - t_budget = ( - thinking_obj.get("budget_tokens") - or thinking_obj.get("budget") + thinking_map = cast(dict[str, Any], thinking_obj) + t_type = thinking_map.get("type") + t_budget = thinking_map.get("budget_tokens") or thinking_map.get( + "budget" ) if isinstance(t_type, str) and t_type: current_request_data["enable_thinking"] = t_type != "disabled" @@ -673,7 +674,11 @@ def cooldown_remaining_seconds(key: str) -> float: if retry_after and retry_after.isdigit(): retry_after_seconds = float(int(retry_after)) if retry_after_seconds is None: - retry_after_seconds = 10.0 + retry_after_seconds = ( + float(proxy_config.failover_429_cooldown_seconds) + if isinstance(proxy_config, ProxyConfig) + else 60.0 + ) key = endpoint_key(endpoint) with self._config_lock: self._endpoint_429_until[key] = time.monotonic() + retry_after_seconds @@ -887,9 +892,10 @@ def simulate_stream() -> Generator[str]: except requests.exceptions.HTTPError as e: error_msg = f"目标 API HTTP 错误: {e.response.status_code} - {e.response.text}" log(error_msg) - if e.response.status_code == 400: + if e.response.status_code == HTTP_STATUS_BAD_REQUEST: with contextlib.suppress(Exception): - log(f"--- 触发 400 错误的请求参数 ---\\n{json.dumps(request_data, indent=2, ensure_ascii=False)}") + request_dump = json.dumps(request_data, indent=2, ensure_ascii=False) + log(f"--- 触发 400 错误的请求参数 ---\\n{request_dump}") with contextlib.suppress(Exception): if e.response is not None: e.response.close() diff --git a/python-src/modules/proxy/proxy_config.py b/python-src/modules/proxy/proxy_config.py index 9c2e798..b81e492 100644 --- a/python-src/modules/proxy/proxy_config.py +++ b/python-src/modules/proxy/proxy_config.py @@ -34,6 +34,7 @@ class ProxyConfig: api_key: str mtga_auth_key: str enable_429_failover: bool + failover_429_cooldown_seconds: int def load_global_config( @@ -182,6 +183,9 @@ def build_proxy_config( api_key=api_endpoints[0].api_key, mtga_auth_key=(global_config.get("mtga_auth_key") or ""), enable_429_failover=bool(global_config.get("enable_429_failover", False)), + failover_429_cooldown_seconds=max( + 1, int(global_config.get("failover_429_cooldown_seconds", 60) or 60) + ), ) diff --git a/python-src/modules/proxy/proxy_transport.py b/python-src/modules/proxy/proxy_transport.py index 4dcab05..f25039a 100644 --- a/python-src/modules/proxy/proxy_transport.py +++ b/python-src/modules/proxy/proxy_transport.py @@ -107,11 +107,18 @@ def extract_sse_events( log_file = None buffer += chunk while True: - sep = buffer.find(b"\n\n") - if sep == -1: + sep_lf = buffer.find(b"\n\n") + sep_crlf = buffer.find(b"\r\n\r\n") + candidates = [pos for pos in (sep_lf, sep_crlf) if pos != -1] + if not candidates: break - event = buffer[:sep] - buffer = buffer[sep + 2 :] + sep = min(candidates) + if sep == sep_crlf: + event = buffer[:sep] + buffer = buffer[sep + 4 :] + else: + event = buffer[:sep] + buffer = buffer[sep + 2 :] yield chunk_index, event if buffer.strip(): log("警告: 上游 SSE 结束时存在未完整分隔的残留数据") diff --git a/python-src/modules/services/config_service.py b/python-src/modules/services/config_service.py index 02d14ff..0ddc17a 100644 --- a/python-src/modules/services/config_service.py +++ b/python-src/modules/services/config_service.py @@ -24,7 +24,7 @@ def load_config_groups(self) -> tuple[list[dict[str, Any]], int]: pass return [], 0 - def load_global_config(self) -> tuple[str, str, bool]: + def load_global_config(self) -> tuple[str, str, bool, int]: try: if os.path.exists(self.config_file): with open(self.config_file, encoding="utf-8") as f: @@ -33,18 +33,22 @@ def load_global_config(self) -> tuple[str, str, bool]: mapped_model_id = config.get("mapped_model_id", "") mtga_auth_key = config.get("mtga_auth_key", "") enable_429_failover = bool(config.get("enable_429_failover", False)) - return mapped_model_id, mtga_auth_key, enable_429_failover + cooldown = config.get("failover_429_cooldown_seconds", 60) + try: + cooldown_seconds = max(1, int(cooldown or 60)) + except Exception: + cooldown_seconds = 60 + return mapped_model_id, mtga_auth_key, enable_429_failover, cooldown_seconds except Exception: pass - return "", "", False + return "", "", False, 60 def save_config_groups( self, config_groups: list[dict[str, Any]], current_index: int = 0, - mapped_model_id: str | None = None, - mtga_auth_key: str | None = None, - enable_429_failover: bool | None = None, + *, + global_config_updates: dict[str, Any] | None = None, ) -> bool: try: config_data: dict[str, Any] = {} @@ -55,12 +59,26 @@ def save_config_groups( config_data["config_groups"] = config_groups config_data["current_config_index"] = current_index - if mapped_model_id is not None: - config_data["mapped_model_id"] = mapped_model_id - if mtga_auth_key is not None: - config_data["mtga_auth_key"] = mtga_auth_key - if enable_429_failover is not None: - config_data["enable_429_failover"] = enable_429_failover + if global_config_updates: + mapped_model_id = global_config_updates.get("mapped_model_id") + if mapped_model_id is not None: + config_data["mapped_model_id"] = mapped_model_id + + mtga_auth_key = global_config_updates.get("mtga_auth_key") + if mtga_auth_key is not None: + config_data["mtga_auth_key"] = mtga_auth_key + + enable_429_failover = global_config_updates.get("enable_429_failover") + if enable_429_failover is not None: + config_data["enable_429_failover"] = bool(enable_429_failover) + + failover_429_cooldown_seconds = global_config_updates.get( + "failover_429_cooldown_seconds" + ) + if failover_429_cooldown_seconds is not None: + config_data["failover_429_cooldown_seconds"] = max( + 1, int(failover_429_cooldown_seconds or 60) + ) os.makedirs(os.path.dirname(self.config_file), exist_ok=True) diff --git a/python-src/modules/services/proxy_orchestration.py b/python-src/modules/services/proxy_orchestration.py index e54f968..855300b 100644 --- a/python-src/modules/services/proxy_orchestration.py +++ b/python-src/modules/services/proxy_orchestration.py @@ -35,9 +35,9 @@ class GlobalConfigCheckResult: def ensure_global_config_ready( *, - load_global_config: Callable[[], tuple[str, str, bool]], + load_global_config: Callable[[], tuple[str, str, bool, int]], ) -> GlobalConfigCheckResult: - mapped_model_id, mtga_auth_key, _ = load_global_config() + mapped_model_id, mtga_auth_key, _, _ = load_global_config() mapped_model_id = (mapped_model_id or "").strip() mtga_auth_key = (mtga_auth_key or "").strip() diff --git a/python-src/mtga_app/__init__.py b/python-src/mtga_app/__init__.py index feeb4ad..855e6d0 100644 --- a/python-src/mtga_app/__init__.py +++ b/python-src/mtga_app/__init__.py @@ -259,6 +259,7 @@ class SaveConfigPayload(BaseModel): mapped_model_id: str | None = None mtga_auth_key: str | None = None enable_429_failover: bool | None = None + failover_429_cooldown_seconds: int | None = None @lru_cache(maxsize=1) @@ -281,25 +282,36 @@ async def greet(body: GreetPayload) -> str: async def load_config() -> dict[str, Any]: config_store = _get_config_store() config_groups, current_index = config_store.load_config_groups() - mapped_model_id, mtga_auth_key, enable_429_failover = config_store.load_global_config() + mapped_model_id, mtga_auth_key, enable_429_failover, cooldown_seconds = ( + config_store.load_global_config() + ) return { "config_groups": config_groups, "current_config_index": current_index, "mapped_model_id": mapped_model_id, "mtga_auth_key": mtga_auth_key, "enable_429_failover": enable_429_failover, + "failover_429_cooldown_seconds": cooldown_seconds, } @command_registry.command() async def save_config(body: SaveConfigPayload) -> bool: config_store = _get_config_store() + global_updates: dict[str, Any] = {} + if body.mapped_model_id is not None: + global_updates["mapped_model_id"] = body.mapped_model_id + if body.mtga_auth_key is not None: + global_updates["mtga_auth_key"] = body.mtga_auth_key + if body.enable_429_failover is not None: + global_updates["enable_429_failover"] = body.enable_429_failover + if body.failover_429_cooldown_seconds is not None: + global_updates["failover_429_cooldown_seconds"] = body.failover_429_cooldown_seconds + return config_store.save_config_groups( body.config_groups, body.current_config_index, - body.mapped_model_id, - body.mtga_auth_key, - body.enable_429_failover, + global_config_updates=global_updates if global_updates else None, ) From 6bda834c0bb06aa03f92fbb3151a6e784fcfa059 Mon Sep 17 00:00:00 2001 From: QuickLAW <89532126+QuickLAW@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:41:45 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=AD=A3=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E7=AD=96=E7=95=A5=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=88?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将“高级策略”改为“转发策略”,明确功能定位 将“API 调度与故障转移”改为“负载均衡与自动容错”,更准确描述功能 调整相关开关和说明文案,使描述更清晰易懂 优化冷却时间输入框样式,提升视觉一致性 --- app/components/panels/SettingsPanel.vue | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/components/panels/SettingsPanel.vue b/app/components/panels/SettingsPanel.vue index c128883..dd3f7da 100644 --- a/app/components/panels/SettingsPanel.vue +++ b/app/components/panels/SettingsPanel.vue @@ -164,8 +164,8 @@ const handleThemeSave = (value: ThemeConfig) => {
-
高级策略
-
API 调度与故障转移
+
转发策略
+
负载均衡与自动容错
-
+
+
轮询配置组(可多选,未选则仅使用当前激活配置)
+
+ +
+
暂无可选配置组
+
- 开启后,请求将在各配置组间轮询分发。若节点触发 429 (Too Many Requests) + 开启后,请求将在选中配置组间轮询分发。若节点触发 429 (Too Many Requests) 频率限制,将自动静默切换至可用节点并对受限节点执行冷却隔离,确保服务连续性。
diff --git a/app/composables/mtgaTypes.ts b/app/composables/mtgaTypes.ts index ba084f4..6768667 100644 --- a/app/composables/mtgaTypes.ts +++ b/app/composables/mtgaTypes.ts @@ -1,4 +1,5 @@ export type ConfigGroup = { + id?: string; name?: string; api_url: string; model_id: string; @@ -13,6 +14,7 @@ export type ConfigPayload = { current_config_index: number; mapped_model_id: string; mtga_auth_key: string; + routing_group_ids?: string[]; enable_429_failover?: boolean; failover_429_cooldown_seconds?: number; }; diff --git a/app/composables/useMtgaStore.ts b/app/composables/useMtgaStore.ts index 939248c..985af87 100644 --- a/app/composables/useMtgaStore.ts +++ b/app/composables/useMtgaStore.ts @@ -162,6 +162,19 @@ const clampIndex = (value: number, max: number) => { return Math.min(Math.max(value, 0), max - 1); }; +const createConfigGroupId = (index: number) => { + if (typeof globalThis !== "undefined" && globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return `group-${Date.now()}-${index}-${Math.random().toString(16).slice(2, 10)}`; +}; + +const normalizeConfigGroups = (groups: ConfigGroup[]) => + groups.map((group, index) => { + const id = coerceText(group.id).trim() || createConfigGroupId(index); + return { ...group, id }; + }); + export const useMtgaStore = () => { const api = useMtgaApi(); @@ -169,6 +182,7 @@ export const useMtgaStore = () => { const currentConfigIndex = useState("mtga-current-config-index", () => 0); const mappedModelId = useState("mtga-mapped-model-id", () => ""); const mtgaAuthKey = useState("mtga-auth-key", () => ""); + const routingGroupIds = useState("mtga-routing-group-ids", () => []); const enable429Failover = useState("mtga-enable-429-failover", () => false); const failover429CooldownSeconds = useState( "mtga-failover-429-cooldown-seconds", @@ -454,11 +468,24 @@ export const useMtgaStore = () => { if (!result) { return false; } - configGroups.value = result.config_groups || []; + const loadedGroups = Array.isArray(result.config_groups) ? result.config_groups : []; + configGroups.value = normalizeConfigGroups(loadedGroups); currentConfigIndex.value = clampIndex( result.current_config_index ?? 0, configGroups.value.length, ); + const availableGroupIds = new Set( + configGroups.value.map((group) => coerceText(group.id).trim()), + ); + routingGroupIds.value = Array.isArray(result.routing_group_ids) + ? Array.from( + new Set( + result.routing_group_ids + .map((id) => coerceText(id).trim()) + .filter((id) => id && availableGroupIds.has(id)), + ), + ) + : []; mappedModelId.value = coerceText(result.mapped_model_id); mtgaAuthKey.value = coerceText(result.mtga_auth_key); enable429Failover.value = Boolean(result.enable_429_failover); @@ -470,13 +497,25 @@ export const useMtgaStore = () => { }; const saveConfig = async () => { + configGroups.value = normalizeConfigGroups(configGroups.value); const clampedIndex = clampIndex(currentConfigIndex.value, configGroups.value.length); currentConfigIndex.value = clampedIndex; + const availableGroupIds = new Set( + configGroups.value.map((group) => coerceText(group.id).trim()), + ); + routingGroupIds.value = Array.from( + new Set( + routingGroupIds.value + .map((id) => coerceText(id).trim()) + .filter((id) => id && availableGroupIds.has(id)), + ), + ); const payload: ConfigPayload = { config_groups: configGroups.value, current_config_index: clampedIndex, mapped_model_id: coerceText(mappedModelId.value), mtga_auth_key: coerceText(mtgaAuthKey.value), + routing_group_ids: routingGroupIds.value, enable_429_failover: enable429Failover.value, failover_429_cooldown_seconds: Math.max( 1, @@ -822,6 +861,7 @@ export const useMtgaStore = () => { currentConfigIndex, mappedModelId, mtgaAuthKey, + routingGroupIds, enable429Failover, failover429CooldownSeconds, runtimeOptions, diff --git a/app/docs/ui-migration.md b/app/docs/ui-migration.md index ccb045e..b143e8e 100644 --- a/app/docs/ui-migration.md +++ b/app/docs/ui-migration.md @@ -56,6 +56,8 @@ app/ - [x] `MainTabs` 支持切换并挂载各 Tab 内容(证书/hosts/代理/数据/关于)。 - [x] `ConfigGroupPanel` 改为可交互:列表数据、选中状态、增删改弹窗。 - [x] `GlobalConfigPanel` 与 `RuntimeOptionsPanel` 接入真实数据与保存逻辑。 +- [x] 新增 `routing_group_ids` 配置读写,支持按组路由选择。 +- [x] 设置页接入配置组多选,轮询仅针对选中组(空则回退全部)。 - [x] `LogPanel` 支持追加日志流(从后端或前端事件)。 - [x] `UpdateDialog`、确认弹窗完善交互与 HTML 内容渲染。 - [x] 用 `pyInvoke` 串起最小功能链路(例如 `greet` -> 日志输出)。 @@ -146,6 +148,7 @@ config_groups: ConfigGroup[] current_config_index: number mapped_model_id: string mtga_auth_key: string +routing_group_ids: string[] runtime_options: { debugMode: boolean disableSslStrict: boolean diff --git a/python-src/modules/proxy/proxy_config.py b/python-src/modules/proxy/proxy_config.py index c880f1b..c864c40 100644 --- a/python-src/modules/proxy/proxy_config.py +++ b/python-src/modules/proxy/proxy_config.py @@ -64,6 +64,47 @@ def _resolve_target_model_id(*, raw_config: dict[str, Any], custom_model_id: str return target_model_id if target_model_id else custom_model_id +def _parse_endpoint_from_group( + group: dict[str, Any], *, custom_model_id: str +) -> ProxyApiEndpoint | None: + url = (group.get("api_url") or "").strip() + if not url or url == PLACEHOLDER_API_URL: + return None + key = (group.get("api_key") or "").strip() + model = (group.get("model_id") or "").strip() or custom_model_id + route = normalize_middle_route(group.get("middle_route")) + return ProxyApiEndpoint( + api_url=url, + api_key=key, + target_model_id=model, + middle_route=route, + ) + + +def _extract_config_groups(global_config: dict[str, Any]) -> list[dict[str, Any]]: + config_groups: list[dict[str, Any]] = [] + raw_groups_obj = global_config.get("config_groups") + if not isinstance(raw_groups_obj, list): + return config_groups + for group_any in cast(list[object], raw_groups_obj): + if isinstance(group_any, dict): + config_groups.append(cast(dict[str, Any], group_any)) + return config_groups + + +def _extract_routing_group_ids(global_config: dict[str, Any]) -> list[str]: + routing_group_ids: list[str] = [] + routing_group_ids_raw = global_config.get("routing_group_ids") + if not isinstance(routing_group_ids_raw, list): + return routing_group_ids + for item in cast(list[object], routing_group_ids_raw): + if isinstance(item, str): + value = item.strip() + if value: + routing_group_ids.append(value) + return routing_group_ids + + def _parse_api_endpoints( *, raw_config: dict[str, Any], @@ -71,67 +112,45 @@ def _parse_api_endpoints( custom_model_id: str, ) -> tuple[ProxyApiEndpoint, ...]: enable_failover = bool(global_config.get("enable_429_failover", False)) + config_groups = _extract_config_groups(global_config) + routing_group_ids = _extract_routing_group_ids(global_config) endpoints: list[ProxyApiEndpoint] = [] - - # 1. Primary endpoint (from raw_config) - api_url_value = raw_config.get("api_url", PLACEHOLDER_API_URL) - if not isinstance(api_url_value, str): - api_url_value = PLACEHOLDER_API_URL - api_key_value = raw_config.get("api_key") or "" - if not isinstance(api_key_value, str): - api_key_value = "" - target_model_id = (raw_config.get("model_id") or "").strip() - if not target_model_id: - target_model_id = custom_model_id - - primary_middle_route = normalize_middle_route(raw_config.get("middle_route")) - - primary_endpoint = ProxyApiEndpoint( - api_url=api_url_value, - api_key=api_key_value, - target_model_id=target_model_id, - middle_route=primary_middle_route, - ) - endpoints.append(primary_endpoint) - - # 2. Failover endpoints - if enable_failover: - raw_groups = global_config.get("config_groups") - if isinstance(raw_groups, list): - config_groups = cast(list[Any], raw_groups) - for group_any in config_groups: - if not isinstance(group_any, dict): - continue - group = cast(dict[str, Any], group_any) - - url = (group.get("api_url") or "").strip() - if not url or url == PLACEHOLDER_API_URL: - continue - - key = (group.get("api_key") or "").strip() - model = (group.get("model_id") or "").strip() - if not model: - model = custom_model_id - - # 解析配置组中的 middle_route,若未配置则使用默认值 - route = normalize_middle_route(group.get("middle_route")) - - if ( - url == api_url_value - and key == api_key_value - and model == target_model_id - and route == primary_middle_route - ): - continue - - endpoints.append( - ProxyApiEndpoint( - api_url=url, - api_key=key, - target_model_id=model, - middle_route=route, - ) - ) + endpoint_signatures: set[tuple[str, str, str, str]] = set() + + def append_unique(group: dict[str, Any]) -> None: + endpoint = _parse_endpoint_from_group(group, custom_model_id=custom_model_id) + if endpoint is None: + return + signature = ( + endpoint.api_url, + endpoint.api_key, + endpoint.target_model_id, + endpoint.middle_route, + ) + if signature in endpoint_signatures: + return + endpoint_signatures.add(signature) + endpoints.append(endpoint) + + selected_mode = bool(enable_failover and routing_group_ids) + if selected_mode: + selected_ids = set(routing_group_ids) + for group in config_groups: + group_id = str(group.get("id") or "").strip() + if group_id and group_id in selected_ids: + append_unique(group) + + # 如果启用了选择模式但结果为空(例如:选中组均无效),回退到仅使用当前激活配置 + # 这意味着如果用户开启轮询但没选任何组,就相当于没开启轮询 + if selected_mode and not endpoints: + selected_mode = False + # 清空以便重新添加单点 + endpoints.clear() + endpoint_signatures.clear() + + # 如果未启用选择模式(或回退),只添加当前激活的配置 + if not selected_mode: + append_unique(raw_config) return tuple(endpoints) @@ -180,6 +199,9 @@ def build_proxy_config( global_config=global_config, custom_model_id=custom_model_id, ) + if not api_endpoints: + log_func("错误: 没有可用的 API 端点") + return None target_api_base_url = api_endpoints[0].api_url if target_api_base_url == PLACEHOLDER_API_URL: log_func("错误: 请在配置中设置正确的 API URL") diff --git a/python-src/modules/services/config_service.py b/python-src/modules/services/config_service.py index 0ddc17a..917246c 100644 --- a/python-src/modules/services/config_service.py +++ b/python-src/modules/services/config_service.py @@ -2,7 +2,7 @@ import os from dataclasses import dataclass -from typing import Any +from typing import Any, cast import yaml @@ -24,7 +24,7 @@ def load_config_groups(self) -> tuple[list[dict[str, Any]], int]: pass return [], 0 - def load_global_config(self) -> tuple[str, str, bool, int]: + def load_global_config(self) -> tuple[str, str, bool, int, list[str]]: try: if os.path.exists(self.config_file): with open(self.config_file, encoding="utf-8") as f: @@ -34,14 +34,32 @@ def load_global_config(self) -> tuple[str, str, bool, int]: mtga_auth_key = config.get("mtga_auth_key", "") enable_429_failover = bool(config.get("enable_429_failover", False)) cooldown = config.get("failover_429_cooldown_seconds", 60) + routing_group_ids_raw = config.get("routing_group_ids") + routing_group_ids: list[str] = [] + if isinstance(routing_group_ids_raw, list): + seen: set[str] = set() + for item in cast(list[object], routing_group_ids_raw): + if not isinstance(item, str): + continue + group_id = item.strip() + if not group_id or group_id in seen: + continue + seen.add(group_id) + routing_group_ids.append(group_id) try: cooldown_seconds = max(1, int(cooldown or 60)) except Exception: cooldown_seconds = 60 - return mapped_model_id, mtga_auth_key, enable_429_failover, cooldown_seconds + return ( + mapped_model_id, + mtga_auth_key, + enable_429_failover, + cooldown_seconds, + routing_group_ids, + ) except Exception: pass - return "", "", False, 60 + return "", "", False, 60, [] def save_config_groups( self, @@ -80,6 +98,21 @@ def save_config_groups( 1, int(failover_429_cooldown_seconds or 60) ) + routing_group_ids_raw = global_config_updates.get("routing_group_ids") + if routing_group_ids_raw is not None: + routing_group_ids: list[str] = [] + seen: set[str] = set() + if isinstance(routing_group_ids_raw, list): + for item in cast(list[object], routing_group_ids_raw): + if not isinstance(item, str): + continue + group_id = item.strip() + if not group_id or group_id in seen: + continue + seen.add(group_id) + routing_group_ids.append(group_id) + config_data["routing_group_ids"] = routing_group_ids + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) with open(self.config_file, "w", encoding="utf-8") as f: diff --git a/python-src/modules/services/proxy_orchestration.py b/python-src/modules/services/proxy_orchestration.py index 855300b..1e36d78 100644 --- a/python-src/modules/services/proxy_orchestration.py +++ b/python-src/modules/services/proxy_orchestration.py @@ -35,9 +35,9 @@ class GlobalConfigCheckResult: def ensure_global_config_ready( *, - load_global_config: Callable[[], tuple[str, str, bool, int]], + load_global_config: Callable[[], tuple[str, str, bool, int, list[str]]], ) -> GlobalConfigCheckResult: - mapped_model_id, mtga_auth_key, _, _ = load_global_config() + mapped_model_id, mtga_auth_key, *_ = load_global_config() mapped_model_id = (mapped_model_id or "").strip() mtga_auth_key = (mtga_auth_key or "").strip() diff --git a/python-src/mtga_app/__init__.py b/python-src/mtga_app/__init__.py index 855e6d0..cf14eba 100644 --- a/python-src/mtga_app/__init__.py +++ b/python-src/mtga_app/__init__.py @@ -258,6 +258,7 @@ class SaveConfigPayload(BaseModel): current_config_index: int mapped_model_id: str | None = None mtga_auth_key: str | None = None + routing_group_ids: list[str] | None = None enable_429_failover: bool | None = None failover_429_cooldown_seconds: int | None = None @@ -282,7 +283,7 @@ async def greet(body: GreetPayload) -> str: async def load_config() -> dict[str, Any]: config_store = _get_config_store() config_groups, current_index = config_store.load_config_groups() - mapped_model_id, mtga_auth_key, enable_429_failover, cooldown_seconds = ( + mapped_model_id, mtga_auth_key, enable_429_failover, cooldown_seconds, routing_group_ids = ( config_store.load_global_config() ) return { @@ -290,6 +291,7 @@ async def load_config() -> dict[str, Any]: "current_config_index": current_index, "mapped_model_id": mapped_model_id, "mtga_auth_key": mtga_auth_key, + "routing_group_ids": routing_group_ids, "enable_429_failover": enable_429_failover, "failover_429_cooldown_seconds": cooldown_seconds, } @@ -303,6 +305,8 @@ async def save_config(body: SaveConfigPayload) -> bool: global_updates["mapped_model_id"] = body.mapped_model_id if body.mtga_auth_key is not None: global_updates["mtga_auth_key"] = body.mtga_auth_key + if body.routing_group_ids is not None: + global_updates["routing_group_ids"] = body.routing_group_ids if body.enable_429_failover is not None: global_updates["enable_429_failover"] = body.enable_429_failover if body.failover_429_cooldown_seconds is not None: