fix: stabilize Router model error handling

This commit is contained in:
potaser
2026-08-22 00:47:11 +08:00
parent 8985f5cbdd
commit 052b0c1d5d
4 changed files with 424 additions and 10 deletions
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any
import pytest
@@ -269,3 +270,203 @@ def test_router_response_language_does_not_rewrite_business_query() -> None:
assert messages[-1].content == query
assert "Respond to the user in English." in messages[0].content
def test_router_generated_model_failure_is_not_replayed_as_history() -> None:
failure_message = (
"Router 调用 DeepSeek 失败(HTTP 402,模型 deepseek-v4-flash,"
"已尝试 3 次,错误码 invalid_request_error):Insufficient Balance"
)
history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": failure_message},
{"role": "assistant", "content": "正常历史回复"},
]
decision = RouteDecision(goal="general", needs=[], subject="hello")
route_messages = RouterRuntime._route_messages(
query="hello",
history=history,
session_id="router-history-session",
job_id="job-router-history",
goal_ledger=None,
)
response_messages = RouterRuntime._initial_messages(
query="hello",
history=history,
session_id="router-history-session",
job_id="job-router-history",
decision=decision,
goal_ledger=None,
)
for messages in (route_messages, response_messages):
contents = [message.content for message in messages]
assert failure_message not in contents
assert "正常历史回复" in contents
def test_route_decision_retries_transient_model_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class TransientPaymentGatewayFailure(RuntimeError):
status_code = 402
class TransientLLM:
calls = 0
async def acall(self, *_: Any, **__: Any) -> Any:
self.calls += 1
if self.calls < 3:
raise TransientPaymentGatewayFailure("temporary billing gateway failure")
return SimpleNamespace(
content=(
'{"goal":"new_analysis","needs":["analysis_intent"],'
'"subject":"执行数据准备","analysis_request_kind":"execution"}'
)
)
async def no_sleep(_: float) -> None:
return None
llm = TransientLLM()
runtime = RouterRuntime(llm, _PassThroughTools(), max_iterations=5) # type: ignore[arg-type]
monkeypatch.setattr(asyncio, "sleep", no_sleep)
decision, calls, trace = asyncio.run(
runtime._decide_route(
query="执行数据准备",
history=[],
session_id="retry-session",
job_id="job-retry",
goal_ledger=None,
)
)
assert llm.calls == 3
assert calls == 1
assert trace == []
assert decision is not None
assert decision.goal == "new_analysis"
def test_route_decision_does_not_retry_authentication_failure() -> None:
class AuthenticationFailure(RuntimeError):
status_code = 401
class UnauthorizedLLM:
calls = 0
async def acall(self, *_: Any, **__: Any) -> Any:
self.calls += 1
raise AuthenticationFailure("unauthorized")
llm = UnauthorizedLLM()
runtime = RouterRuntime(llm, _PassThroughTools(), max_iterations=5) # type: ignore[arg-type]
decision, calls, trace = asyncio.run(
runtime._decide_route(
query="执行数据准备",
history=[],
session_id="auth-session",
job_id="job-auth",
goal_ledger=None,
)
)
assert llm.calls == 1
assert decision is None
assert calls == 1
assert trace == []
def test_route_failure_exposes_sanitized_upstream_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class StructuredPaymentGatewayFailure(RuntimeError):
status_code = 402
request_id = "req-router-402"
body = {
"error": {
"code": "request_policy_rejected",
"message": (
"the concrete upstream reason; "
"Authorization: Bearer must-not-leak"
),
"api_key": "must-not-leak",
}
}
class FailingLLM:
_default_model = "deepseek-v4-flash"
calls = 0
async def acall(self, *_: Any, **__: Any) -> Any:
self.calls += 1
raise StructuredPaymentGatewayFailure("generic SDK error")
async def no_sleep(_: float) -> None:
return None
llm = FailingLLM()
runtime = RouterRuntime(llm, _PassThroughTools(), max_iterations=5) # type: ignore[arg-type]
monkeypatch.setattr(asyncio, "sleep", no_sleep)
outcome = asyncio.run(
runtime.run(
query="执行数据准备",
history=[],
session_id="failure-detail-session",
job_id="job-failure-detail",
goal_ledger=None,
)
)
assert llm.calls == 3
assert outcome.status == "agent_unavailable"
assert "HTTP 402" in outcome.message
assert "request_policy_rejected" in outcome.message
assert "the concrete upstream reason" in outcome.message
assert "deepseek-v4-flash" in outcome.message
assert "3 次" in outcome.message
assert "req-router-402" in outcome.message
assert "must-not-leak" not in outcome.message
def test_route_failure_reads_flat_openai_error_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FlatOpenAIError(RuntimeError):
status_code = 402
body = {
"code": "invalid_request_error",
"message": "Insufficient Balance",
"type": "unknown_error",
}
class FailingLLM:
_default_model = "deepseek-v4-flash"
async def acall(self, *_: Any, **__: Any) -> Any:
raise FlatOpenAIError("serialized SDK fallback")
async def no_sleep(_: float) -> None:
return None
runtime = RouterRuntime(FailingLLM(), _PassThroughTools(), max_iterations=5) # type: ignore[arg-type]
monkeypatch.setattr(asyncio, "sleep", no_sleep)
outcome = asyncio.run(
runtime.run(
query="执行数据准备",
history=[],
session_id="flat-openai-error-session",
job_id="job-flat-openai-error",
goal_ledger=None,
)
)
assert outcome.status == "agent_unavailable"
assert "错误码 invalid_request_error" in outcome.message
assert outcome.message.endswith("Insufficient Balance")
assert "serialized SDK fallback" not in outcome.message
@@ -0,0 +1,78 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def test_web_import_keeps_plantai_new_api_bundle_when_rag_import_sets_deepseek_key(
tmp_path: Path,
) -> None:
rag_root = tmp_path / "rag"
module_dir = rag_root / "src" / "rag" / "unified"
module_dir.mkdir(parents=True)
for package_dir in (rag_root / "src", rag_root / "src" / "rag", module_dir):
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(module_dir / "bbav5_methodology_config.py").write_text(
(
"import os\n"
"os.environ.setdefault('DEEPSEEK_API_KEY', 'external-stale-key')\n"
"def build_bbav5_methodology_config(*args, **kwargs):\n"
" return {}\n"
),
encoding="utf-8",
)
runtime_root = tmp_path / "runtime"
runtime_root.mkdir()
env = os.environ.copy()
for name in (
"DEEPSEEK_API_KEY",
"deepseek_api_key",
"NEW_API_KEY",
"NEW_API_BASE_URL",
"NEW_API_MODEL",
):
env.pop(name, None)
env.update(
{
"NEW_API_KEY": "plantai-current-key",
"NEW_API_BASE_URL": "https://api.deepseek.com",
"NEW_API_MODEL": "deepseek-v4-flash",
"PLANTAI_RAG_ROOT": str(rag_root),
"GF_MAS_ROOT": str(tmp_path / "missing-gf-mas"),
"BBAV5_PROJECT_ROOT": str(runtime_root),
"PLANTAI_PROJECT_ROOT": str(runtime_root),
}
)
result = subprocess.run(
[
sys.executable,
"-c",
(
"import os\n"
"from pathlib import Path\n"
f"path = Path({str(PROJECT_ROOT / 'apps/web/vue3/main.py')!r})\n"
"source = path.read_text(encoding='utf-8')\n"
"marker = 'try:\\n from gfa_core.mas.agents.interpretation_evidence'\n"
"prefix, separator, _ = source.partition(marker)\n"
"assert separator\n"
"namespace = {'__file__': str(path), '__name__': 'web_import_prefix'}\n"
"exec(compile(prefix, str(path), 'exec'), namespace)\n"
"assert 'DEEPSEEK_API_KEY' not in os.environ\n"
),
],
cwd=PROJECT_ROOT,
env=env,
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr