From 26d238a33162116e5e3b0ec21f05af9efc728949 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 8 Apr 2026 11:50:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(tiers):=20=E4=BF=AE=E5=A4=8D=20ZhipuVendor?= =?UTF-8?q?=20=E6=9C=AA=E6=B3=A8=E5=85=A5=20FailoverConfig=20=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E9=9D=9E=E6=B5=81=E5=BC=8F=20429=20=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=20tier=20=E9=99=8D=E7=BA=A7=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:factory.py 中 ZhipuVendor 构造时未传入 failover_cfg 参数, 导致 BaseVendor._failover_config 为 None,should_trigger_failover() 永远返回 False。 当 Zhipu 在 message(非流式)路径返回临时 429 时,executor 无法判定应降级, 直接将 429 原样返回给客户端导致任务中断。 修复: - zhipu.py: __init__ 新增 failover_config 可选参数并转发至基类 - factory.py: _create_vendor_from_config 中给 ZhipuVendor 注入 failover_cfg - test_router_executor.py: 新增 TestExecuteMessageFailoverOn429 测试类(5 个用例) 测试结果:971/971 passed,零回归 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/coding/proxy/server/factory.py | 2 +- src/coding/proxy/vendors/zhipu.py | 7 +- tests/test_router_executor.py | 175 +++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/coding/proxy/server/factory.py b/src/coding/proxy/server/factory.py index 5f9be1e..5ca7ee3 100644 --- a/src/coding/proxy/server/factory.py +++ b/src/coding/proxy/server/factory.py @@ -150,7 +150,7 @@ def _create_vendor_from_config( api_key=vendor_cfg.api_key, timeout_ms=vendor_cfg.timeout_ms, ) - return ZhipuVendor(cfg, mapper) + return ZhipuVendor(cfg, mapper, failover_cfg) case _: raise ValueError(f"未知的 vendor 类型: {vendor_cfg.vendor!r}") diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index 152d3db..af47474 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -16,7 +16,7 @@ import httpx -from ..config.schema import ZhipuConfig +from ..config.schema import FailoverConfig, ZhipuConfig from ..routing.model_mapper import ModelMapper from .base import ( PROXY_SKIP_HEADERS, @@ -42,8 +42,11 @@ def __init__( self, config: ZhipuConfig, model_mapper: ModelMapper, + failover_config: FailoverConfig | None = None, ) -> None: - super().__init__(config.base_url, config.timeout_ms) + super().__init__( + config.base_url, config.timeout_ms, failover_config=failover_config + ) self._api_key = config.api_key self._model_mapper = model_mapper diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 0034345..13c4e5f 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -938,3 +938,178 @@ async def test_last_tier_500_with_tool_results_logs_context(self, caplog): ) assert "has_tool_results=True" in log_text assert "claude-opus-4-6" in log_text + + +# ── execute_message 429 降级测试 ───────────────────────────── + + +class TestExecuteMessageFailoverOn429: + """非流式路径下 vendor 返回 429 时应触发 tier 降级. + + 覆盖核心修复场景:ZhipuVendor 未注入 FailoverConfig 导致 + should_trigger_failover() 永远返回 False,429 无法降级到下一层。 + """ + + @pytest.mark.asyncio + async def test_429_with_failover_config_triggers_failover(self): + """非 terminal 层 vendor 有 failover_config 时,429 应触发降级到下一层.""" + # 模拟 zhipu 层返回 429(有 failover_config → should_trigger_failover 返回 True) + zhipu = _mock_vendor("zhipu") + zhipu.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body='{"error":{"code":"1305","message":"\u8be5\u6a21\u578b\u5f53\u524d\u8bbf\u95ee\u91cf\u8fc7\u5927"}}'.encode(), + error_type=None, + error_message="该模型当前访问量过大,请您稍后再试", + ) + ) + zhipu.should_trigger_failover.return_value = True + + # copilot 层正常响应 + copilot = _mock_vendor("copilot") + copilot.send_message = AsyncMock( + return_value=VendorResponse( + status_code=200, + raw_body=b'{"content":"ok"}', + usage=UsageInfo(input_tokens=1, output_tokens=1), + ) + ) + + zhipu_tier = _make_tier(zhipu) + exec_inst = _executor([zhipu_tier, _make_tier(copilot)]) + resp = await exec_inst.execute_message({"model": "test"}, {}) + + assert resp.status_code == 200 + assert copilot.send_message.called + # record_failure 在 VendorTier 上(非 vendor),通过 circuit_breaker 调用链验证 + if zhipu_tier.circuit_breaker: + assert zhipu_tier.circuit_breaker.failure_count > 0 + + @pytest.mark.asyncio + async def test_429_without_failover_config_no_failover(self): + """非 terminal 层 vendor 无 failover_config 时,429 不应触发降级(原始行为).""" + bad = _mock_vendor("bad") + bad.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body=b'{"error":{"message":"rate limited"}}', + error_type=None, + error_message="rate limited", + ) + ) + # 显式设为 False:模拟无 failover_config 时 should_trigger_failover 的行为 + bad.should_trigger_failover.return_value = False + + good = _mock_vendor("good") + good_resp = VendorResponse( + status_code=200, + raw_body=b"{}", + usage=UsageInfo(input_tokens=1, output_tokens=1), + ) + good.send_message = AsyncMock(return_value=good_resp) + + exec_inst = _executor([_make_tier(bad), _make_tier(good)]) + resp = await exec_inst.execute_message({"model": "test"}, {}) + + # 无 failover_config 时直接返回 429,不降级 + assert resp.status_code == 429 + assert not good.send_message.called + + @pytest.mark.asyncio + async def test_multi_tier_429_cascade(self): + """多层降级链路:anthropic→zhipu(429)→copilot(success).""" + anthropic = _mock_vendor("anthropic") + anthropic.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body=b'{"error":{"type":"rate_limit_error","message":"quota exceeded"}}', + error_type="rate_limit_error", + error_message="quota exceeded", + ) + ) + anthropic.should_trigger_failover.return_value = True + + zhipu = _mock_vendor("zhipu") + zhipu.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body=b'{"error":{"code":"1305","message":"model overloaded"}}', + error_type=None, + error_message="模型过载", + ) + ) + zhipu.should_trigger_failover.return_value = True + + copilot = _mock_vendor("copilot") + copilot.send_message = AsyncMock( + return_value=VendorResponse( + status_code=200, + raw_body=b'{"content":"fallback success"}', + usage=UsageInfo(input_tokens=10, output_tokens=5), + ) + ) + + exec_inst = _executor([ + _make_tier(anthropic), + _make_tier(zhipu), + _make_tier(copilot), + ]) + resp = await exec_inst.execute_message({"model": "claude-opus-4-6"}, {}) + + assert resp.status_code == 200 + assert anthropic.send_message.called + assert zhipu.send_message.called + assert copilot.send_message.called + + @pytest.mark.asyncio + async def test_last_tier_429_returns_directly(self): + """最后一层 vendor 返回 429 时应直接返回给客户端.""" + vendor = _mock_vendor() + vendor.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body=b'{"error":{"message":"too many requests"}}', + error_type=None, + error_message="too many requests", + ) + ) + + exec_inst = _executor([_make_tier(vendor)]) + resp = await exec_inst.execute_message({"model": "test"}, {}) + + assert resp.status_code == 429 + + @pytest.mark.asyncio + async def test_429_failover_logs_warning(self, caplog): + """429 触发降级时应输出 'failing over' 日志.""" + import logging as _logging + + bad = _mock_vendor("zhipu") + bad.send_message = AsyncMock( + return_value=VendorResponse( + status_code=429, + raw_body=b'{"error":{"message":"overloaded"}}', + error_type=None, + error_message="overloaded", + ) + ) + bad.should_trigger_failover.return_value = True + + good = _mock_vendor("copilot") + good.send_message = AsyncMock( + return_value=VendorResponse( + status_code=200, + raw_body=b"{}", + usage=UsageInfo(), + ) + ) + + exec_inst = _executor([_make_tier(bad), _make_tier(good)]) + + with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"): + await exec_inst.execute_message({"model": "test"}, {}) + + warnings = [r for r in caplog.records if r.levelno == _logging.WARNING] + log_text = "\n".join(r.message for r in warnings) + assert "failing over" in log_text + assert "zhipu" in log_text