"""tool.llm 自检:本地 Mock OpenAI 兼容服务,验证双模式 URL、重试、备用模型降级、embedding。 运行:python test_llm.py(纯 assert,无框架;可通过 exitcode 判断) """ import asyncio import http.server import json import threading from config.settings import settings from tool.llm import LLMFailError, LLMClient INDEX = json.dumps({"index": 0}).encode() class MockLLMServer(http.server.BaseHTTPRequestHandler): """OpenAI 兼容 Mock:chat/embeddings/tags。model=boom:1b 返回 500 用于测降级。""" def log_message(self, *args): pass def _reply(self, code: int, obj: dict): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): # ollama /api/tags 健康探测 self._reply(200, {"models": [{"name": "qwen2.5:7b"}]}) def do_POST(self): n = int(self.headers.get("Content-Length", 0)) req = json.loads(self.rfile.read(n)) if self.path == "/v1/chat/completions": model = req["model"] if model.startswith("boom"): self._reply(500, {"error": "model overloaded"}) return self._reply(200, {"choices": [{"message": {"content": f"reply:{model}"}}]}) elif self.path == "/v1/embeddings": texts = req["input"] self._reply(200, {"data": [{"embedding": [float(i + 1)] * 4} for i in range(len(texts))]}) else: self._reply(404, {"error": "not found"}) def start_server() -> tuple[threading.Thread, int]: srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), MockLLMServer) port = srv.server_address[1] t = threading.Thread(target=srv.serve_forever, daemon=True) t.start() return t, port def cfg(base_url: str, **kw): """从 .env 加载的 settings.llm 派生测试配置(只覆盖用例所需字段)。""" kw.setdefault("mode", "ollama") kw.setdefault("ollama_base", base_url) kw.setdefault("max_retries", 2) kw.setdefault("retry_backoff_sec", 0) return settings.llm.model_copy(update=kw) async def achat(c: LLMClient, text: str = "hi") -> str: return await c.chat([{"role": "user", "content": text}]) def main(): _, port = start_server() base = f"http://127.0.0.1:{port}" # 1. ollama 模式 URL + 正常 chat c = LLMClient(cfg(base, ollama_chat_model="ok:1b")) assert c.is_ollama and c.base_url == f"{base}/v1", c.base_url out = asyncio.run(achat(c)) assert out == "reply:ok:1b", out # 2. 备用模型降级:主模型 500 → 自动切 fallback_chat_model c2 = LLMClient(cfg(base, ollama_chat_model="boom:1b", ollama_embed_model="bge-m3", fallback_chat_model="ok:1b")) out = asyncio.run(achat(c2)) assert out == "reply:ok:1b", out # 3. 全部失败 → LLMFailError(重试耗尽) c3 = LLMClient(cfg(base, ollama_chat_model="boom:1b", max_retries=1)) try: asyncio.run(achat(c3)) raise AssertionError("should have raised LLMFailError") except LLMFailError: pass # 4. API 模式:端点/鉴权头/模型选择 c4 = LLMClient(settings.llm.model_copy(update={ "mode": "api", "api_base": base + "/v1", "api_key": "sk-test", "api_chat_model": "gpt-x", "max_retries": 1, })) assert not c4.is_ollama and c4.base_url == f"{base}/v1" assert c4._headers()["Authorization"] == "Bearer sk-test" assert c4.chat_model == "gpt-x" out = asyncio.run(achat(c4)) assert out == "reply:gpt-x", out # 5. embedding 批量 c5 = LLMClient(cfg(base)) vecs = asyncio.run(c5.embed(["基金", "债券"])) assert len(vecs) == 2 and len(vecs[0]) == 4, vecs assert asyncio.run(c5.embed_one("货币")) == [1.0] * 4 # 6. 健康探测(ollama /api/tags) asyncio.run(c5.check_health()) print("ALL LLM TESTS PASSED") if __name__ == "__main__": main()