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) => {
+
+
+
+
+ 开启后,请求会在配置组间轮询分发;当某个节点触发 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 调度与故障转移
+
转发策略
+
负载均衡与自动容错
-