refactor: centralize llm provider transport
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from assistant_agent import ReviewAssistantError, stream_review_assistant
|
||||
from backend.llm import transport
|
||||
from heaven_agent import HeavenAgentError, interpret_heaven
|
||||
from llm_strategy import LLMCompilerError, test_llm_connection
|
||||
from mentor_agent import MentorAgentError, MentorSkill, stream_with_mentor
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, *, payload: bytes = b"", lines: list[bytes] | None = None) -> None:
|
||||
self.payload = payload
|
||||
self.lines = lines or []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.payload
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.lines)
|
||||
|
||||
|
||||
class OpenAITransportTests(unittest.TestCase):
|
||||
def test_chat_completion_builds_one_openai_compatible_request(self) -> None:
|
||||
captured = {}
|
||||
response = FakeResponse(
|
||||
payload=json.dumps(
|
||||
{"choices": [{"message": {"content": "OK"}}]}
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
def open_request(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = request.headers
|
||||
captured["payload"] = json.loads(request.data.decode("utf-8"))
|
||||
captured["timeout"] = timeout
|
||||
return response
|
||||
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
|
||||
result = transport.chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1/",
|
||||
model="model",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
|
||||
self.assertEqual(result.content, "OK")
|
||||
self.assertGreaterEqual(result.latency_ms, 0)
|
||||
self.assertEqual(captured["url"], "https://example.test/v1/chat/completions")
|
||||
self.assertEqual(captured["payload"]["stream"], False)
|
||||
self.assertEqual(captured["headers"]["Authorization"], "Bearer secret")
|
||||
self.assertEqual(captured["timeout"], 17)
|
||||
|
||||
def test_stream_completion_parses_deltas_and_ignores_final_snapshot(self) -> None:
|
||||
response = FakeResponse(
|
||||
lines=[
|
||||
b'data: {"choices":[{"delta":{"content":"first"}}]}\n',
|
||||
b'data: {"choices":[{"delta":{"content":" second"}}]}\n',
|
||||
b'data: {"choices":[{"message":{"content":"first second"}}]}\n',
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
)
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
chunks = list(
|
||||
transport.stream_chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
)
|
||||
self.assertEqual(chunks, ["first", " second"])
|
||||
|
||||
def test_empty_stream_has_a_stable_transport_error(self) -> None:
|
||||
response = FakeResponse(lines=[b"data: [DONE]\n"])
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
with self.assertRaises(transport.OpenAIEmptyResponseError):
|
||||
list(
|
||||
transport.stream_chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
)
|
||||
|
||||
def test_http_error_keeps_code_and_sanitized_provider_detail(self) -> None:
|
||||
error = urllib.error.HTTPError(
|
||||
"https://example.test/v1/chat/completions",
|
||||
429,
|
||||
"rate limited",
|
||||
{},
|
||||
io.BytesIO(b'{"error":{"message":"capacity"}}'),
|
||||
)
|
||||
self.addCleanup(error.close)
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(transport.OpenAIHTTPError) as caught:
|
||||
transport.chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
self.assertEqual(caught.exception.code, 429)
|
||||
self.assertEqual(
|
||||
caught.exception.describe("模型调用失败"),
|
||||
"模型调用失败(HTTP 429):capacity",
|
||||
)
|
||||
|
||||
def test_feature_agents_have_no_direct_provider_transport(self) -> None:
|
||||
paths = (
|
||||
"backend/features/mentor/agent.py",
|
||||
"backend/features/heaven/agent.py",
|
||||
"backend/features/review/agent.py",
|
||||
"backend/features/screener/compiler.py",
|
||||
)
|
||||
for relative in paths:
|
||||
source = (ROOT / relative).read_text(encoding="utf-8")
|
||||
with self.subTest(path=relative):
|
||||
self.assertNotIn("urllib.request", source)
|
||||
self.assertNotIn("/chat/completions", source)
|
||||
self.assertIn("llm_transport.", source)
|
||||
|
||||
|
||||
class FeatureErrorMappingTests(unittest.TestCase):
|
||||
def test_feature_specific_http_messages_are_preserved(self) -> None:
|
||||
error = transport.OpenAIHTTPError(429, "capacity")
|
||||
skill = MentorSkill(
|
||||
skill_id="test",
|
||||
name="测试老师",
|
||||
description="",
|
||||
tagline="",
|
||||
focus=(),
|
||||
content="",
|
||||
path=Path("SKILL.md"),
|
||||
)
|
||||
with patch(
|
||||
"mentor_agent.llm_transport.stream_chat_completion", side_effect=error
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
MentorAgentError, "问师模型调用失败(HTTP 429):capacity"
|
||||
):
|
||||
list(stream_with_mentor(skill, {}, "问题", [], "key", "https://x", "m"))
|
||||
with patch("heaven_agent.llm_transport.chat_completion", side_effect=error):
|
||||
with self.assertRaisesRegex(
|
||||
HeavenAgentError, "问天模型调用失败(HTTP 429):capacity"
|
||||
):
|
||||
interpret_heaven("heart", {}, "key", "https://x", "m")
|
||||
with patch(
|
||||
"assistant_agent.llm_transport.stream_chat_completion", side_effect=error
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ReviewAssistantError, "智能解读服务暂不可用(429)"
|
||||
):
|
||||
list(stream_review_assistant({}, "问题", [], "key", "https://x", "m"))
|
||||
with patch("llm_strategy.llm_transport.chat_completion", side_effect=error):
|
||||
with self.assertRaisesRegex(
|
||||
LLMCompilerError, "模型连接测试失败(HTTP 429):capacity"
|
||||
):
|
||||
test_llm_connection("key", "https://x", "m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,7 +44,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
captured["accept"] = request.headers.get("Accept")
|
||||
return FakeStreamResponse(self.lines)
|
||||
|
||||
with patch("mentor_agent.urllib.request.urlopen", side_effect=open_request):
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
|
||||
chunks = list(
|
||||
stream_with_mentor(
|
||||
self.skill, {"data_trade_date": "20260723"}, "怎么看?", [],
|
||||
@@ -58,7 +58,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
|
||||
def test_non_streaming_compatibility_wrapper_collects_chunks(self):
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(self.lines),
|
||||
):
|
||||
result = chat_with_mentor(
|
||||
@@ -74,7 +74,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(lines),
|
||||
):
|
||||
chunks = list(
|
||||
@@ -92,7 +92,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(lines),
|
||||
):
|
||||
chunks = list(
|
||||
|
||||
@@ -91,11 +91,12 @@ class HeavenSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
for name in sorted(expected - (adapted or set())):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_heaven_agent_is_an_exact_file(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "heaven_agent.py"),
|
||||
sha256(APP_ROOT / "backend" / "features" / "heaven" / "agent.py"),
|
||||
)
|
||||
def test_heaven_agent_uses_shared_transport(self) -> None:
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "agent.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("llm_transport.chat_completion", source)
|
||||
self.assertNotIn("urllib.request", source)
|
||||
|
||||
def test_heaven_engine_definitions_are_exact_original_ast(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -105,11 +105,12 @@ class MentorLLMSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
for name in sorted(expected):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_mentor_agent_and_stream_accumulator_are_exact_files(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "mentor_agent.py"),
|
||||
sha256(APP_ROOT / "backend" / "features" / "mentor" / "agent.py"),
|
||||
def test_mentor_agent_uses_shared_transport_and_stream_accumulator_is_exact(self) -> None:
|
||||
mentor_source = (APP_ROOT / "backend" / "features" / "mentor" / "agent.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("llm_transport.stream_chat_completion", mentor_source)
|
||||
self.assertNotIn("urllib.request", mentor_source)
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "llm_stream.py"),
|
||||
sha256(APP_ROOT / "backend" / "llm" / "stream.py"),
|
||||
|
||||
@@ -109,11 +109,12 @@ class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
for name in sorted(expected):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_review_assistant_agent_is_an_exact_file(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "assistant_agent.py"),
|
||||
sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"),
|
||||
)
|
||||
def test_review_assistant_agent_uses_shared_transport(self) -> None:
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "review" / "agent.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("llm_transport.stream_chat_completion", source)
|
||||
self.assertNotIn("urllib.request", source)
|
||||
|
||||
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
|
||||
self.assertIs(assistant_agent, canonical_agent)
|
||||
|
||||
@@ -170,12 +170,16 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_library_and_compiler_files_are_exact_copies(self) -> None:
|
||||
for original, migrated in (
|
||||
("advanced_strategies.py", "backend/features/screener/strategies.py"),
|
||||
("llm_strategy.py", "backend/features/screener/compiler.py"),
|
||||
):
|
||||
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
|
||||
def test_library_is_exact_and_compiler_uses_shared_transport(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "advanced_strategies.py"),
|
||||
sha256(APP_ROOT / "backend/features/screener/strategies.py"),
|
||||
)
|
||||
compiler_source = (
|
||||
APP_ROOT / "backend/features/screener/compiler.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertEqual(compiler_source.count("llm_transport.chat_completion"), 2)
|
||||
self.assertNotIn("urllib.request", compiler_source)
|
||||
|
||||
def test_compatibility_modules_export_the_canonical_objects(self) -> None:
|
||||
self.assertIs(screener, engine)
|
||||
|
||||
@@ -31,7 +31,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
|
||||
b'data: [DONE]\n',
|
||||
]
|
||||
)
|
||||
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
chunks = list(
|
||||
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
|
||||
)
|
||||
@@ -39,7 +39,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
|
||||
|
||||
def test_empty_stream_is_rejected(self):
|
||||
response = StreamingResponse([b"data: [DONE]\n"])
|
||||
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
with self.assertRaises(ReviewAssistantError):
|
||||
list(
|
||||
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
|
||||
@@ -54,7 +54,7 @@ class ReviewAssistantStreamingTests(unittest.TestCase):
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
)
|
||||
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
chunks = list(
|
||||
stream_review_assistant(
|
||||
{}, "question", [], "key", "https://example.test/v1", "model"
|
||||
|
||||
Reference in New Issue
Block a user