PMFlow/tests/test_llm_fail_fast.py

102 lines
3.8 KiB
Python

import httpx
from backend.app.services.llm import LLMService
def _build_http_status_error(status_code: int) -> httpx.HTTPStatusError:
request = httpx.Request("POST", "https://example.com/v1/chat/completions")
response = httpx.Response(status_code, request=request)
return httpx.HTTPStatusError(
message=f"status={status_code}",
request=request,
response=response,
)
def test_fail_fast_for_auth_status_errors():
assert LLMService._is_fail_fast_exception(_build_http_status_error(401))
assert LLMService._is_fail_fast_exception(_build_http_status_error(403))
assert not LLMService._is_fail_fast_exception(_build_http_status_error(500))
def test_fail_fast_for_empty_response_markers():
assert LLMService._is_fail_fast_exception(ValueError("primary_engine_empty_response"))
assert LLMService._is_fail_fast_exception(ValueError("LLM 返回空内容"))
assert LLMService._is_fail_fast_exception(ValueError("provider_circuit_open:openai:gpt-4o"))
def test_provider_circuit_breaker_opens_after_threshold():
service = LLMService()
circuit_key = service._provider_circuit_key(
provider="openai",
base_url="https://example.com/v1",
model="MiniMax-M2.5",
)
LLMService._provider_circuit_state.pop(circuit_key, None)
service._record_provider_failure(circuit_key, ValueError("LLM 返回空内容"))
assert not service._is_provider_circuit_open(circuit_key)
service._record_provider_failure(circuit_key, ValueError("LLM 返回空内容"))
assert service._is_provider_circuit_open(circuit_key)
service._record_provider_success(circuit_key)
assert not service._is_provider_circuit_open(circuit_key)
def test_extract_structured_text_from_reasoning_returns_json_candidate():
service = LLMService()
text = service._extract_structured_text_from_reasoning(
message={"reasoning_content": "先分析,再输出结果:{\"ok\": true, \"items\": [1, 2]}"},
choice={},
response={},
)
assert text == '{"ok": true, "items": [1, 2]}'
def test_extract_structured_text_from_reasoning_ignores_non_json_reasoning():
service = LLMService()
text = service._extract_structured_text_from_reasoning(
message={"reasoning_content": "先分析需求,再决定是否返回。"},
choice={},
response={},
)
assert text == ""
def test_prefer_fast_local_fallback_for_signal_source_functions():
assert LLMService._prefer_fast_local_fallback("signal_source_onboarding_plan")
assert LLMService._prefer_fast_local_fallback("openclaw_command_dispatch")
assert LLMService._prefer_fast_local_fallback("signal_source_ai_worker_execution")
assert not LLMService._prefer_fast_local_fallback("adaptive_rag_answer_normal")
def test_request_timeout_for_fast_local_fallback_functions(monkeypatch):
monkeypatch.delenv("LLM_FAST_LOCAL_FALLBACK_TIMEOUT", raising=False)
service = LLMService()
service.request_timeout = 90.0
assert service._request_timeout_for_function("signal_source_onboarding_plan") == 8.0
assert service._request_timeout_for_function("openclaw_command_dispatch") == 8.0
assert service._request_timeout_for_function("adaptive_rag_answer_normal") == 90.0
def test_request_timeout_for_fast_local_fallback_respects_runtime_cap(monkeypatch):
monkeypatch.setenv("LLM_FAST_LOCAL_FALLBACK_TIMEOUT", "20")
service = LLMService()
service.request_timeout = 8.0
assert service._request_timeout_for_function("signal_source_ai_worker_execution") == 8.0
def test_fast_local_fallback_functions_skip_provider_circuit_breaker():
service = LLMService()
assert not service._use_provider_circuit_breaker("signal_source_onboarding_plan")
assert not service._use_provider_circuit_breaker("openclaw_command_dispatch")
assert service._use_provider_circuit_breaker("adaptive_rag_answer_normal")