Skip to content

Commit c4147d5

Browse files
committed
fix: harden provider failure contracts
1 parent 05891a5 commit c4147d5

12 files changed

Lines changed: 139 additions & 83 deletions

File tree

src/providers/anthropic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def invoke(
8282
logger.info(f"Anthropic LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s")
8383
except Exception as e:
8484
logger.error(f"Anthropic LLM ({self._model_name}) 出错: {e}", exc_info=True)
85-
yield f"抱歉,Anthropic 遇到错误: {str(e)}"
85+
raise
8686

8787
@retry(
8888
stop=stop_after_attempt(3),
@@ -126,4 +126,4 @@ async def ainvoke(
126126
logger.info(f"Anthropic LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s")
127127
except Exception as e:
128128
logger.error(f"Anthropic LLM ({self._model_name}) 异步出错: {e}", exc_info=True)
129-
yield f"抱歉,Anthropic 异步处理遇到错误: {str(e)}"
129+
raise

src/providers/google.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def invoke(
7474
logger.info(f"Google LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s")
7575
except Exception as e:
7676
logger.error(f"Google LLM ({self._model_name}) 出错: {e}", exc_info=True)
77-
yield f"抱歉,Google LLM 遇到错误: {str(e)}"
77+
raise
7878

7979
@retry(
8080
stop=stop_after_attempt(3),
@@ -111,7 +111,7 @@ async def ainvoke(
111111
logger.info(f"Google LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s")
112112
except Exception as e:
113113
logger.error(f"Google LLM ({self._model_name}) 异步出错: {e}", exc_info=True)
114-
yield f"抱歉,Google LLM 异步处理遇到错误: {str(e)}"
114+
raise
115115

116116
@retry(
117117
stop=stop_after_attempt(3),
@@ -137,7 +137,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
137137
return embeddings
138138
except Exception as e:
139139
logger.error(f"Google Embedding ({self._model_name}) 出错: {e}", exc_info=True)
140-
return [[] for _ in texts]
140+
raise
141141

142142
@retry(
143143
stop=stop_after_attempt(3),
@@ -163,4 +163,4 @@ async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
163163
return embeddings
164164
except Exception as e:
165165
logger.error(f"Google Embedding ({self._model_name}) 异步出错: {e}", exc_info=True)
166-
return [[] for _ in texts]
166+
raise

src/providers/grok.py

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,9 @@
11
# -*- coding: utf-8 -*-
2-
from typing import Any, Dict, Generator, List
2+
from src.providers.openai_compatible import OpenAICompatibleProvider
33

4-
import requests
54

6-
from src.providers.__base__.model_provider import LargeLanguageModel
7-
from src.utils.config import settings
8-
9-
10-
class GrokProvider(LargeLanguageModel):
11-
"""
12-
Grok模型提供商。
13-
"""
5+
class GrokProvider(OpenAICompatibleProvider):
6+
"""Grok 模型提供商。"""
147

158
def __init__(self, model_name: str):
16-
self._model_name = model_name
17-
self._api_key = settings.grok_api_key
18-
if not self._api_key:
19-
raise ValueError("Grok配置不完整:缺少 GROK_API_KEY。")
20-
self._base_url = str(settings.grok_base_url)
21-
22-
def invoke(
23-
self,
24-
prompt: str,
25-
system_prompt: str | None = "You are a helpful assistant.",
26-
tools: List[Dict[str, Any]] | None = None,
27-
stream: bool = True,
28-
temperature: float = 0.7,
29-
) -> Generator[str, None, None]:
30-
"""
31-
调用Grok模型。
32-
Grok API当前不支持流式响应,因此我们返回一个包含完整结果的生成器。
33-
"""
34-
url = f"{self._base_url.rstrip('/')}/chat/completions"
35-
headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
36-
37-
# Grok API 不直接支持 system_prompt,但可以将其作为第一条消息
38-
messages = [
39-
{"role": "system", "content": system_prompt},
40-
{"role": "user", "content": prompt}
41-
]
42-
43-
payload = {
44-
"model": self._model_name,
45-
"messages": messages,
46-
"temperature": temperature,
47-
# "tools": tools, # Grok API 可能不支持或有不同的工具格式
48-
}
49-
50-
try:
51-
response = requests.post(url, headers=headers, json=payload, timeout=60)
52-
response.raise_for_status()
53-
result = response.json()
54-
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
55-
yield content.strip()
56-
except Exception as e:
57-
error_message = f"Grok LLM ({self._model_name}) 生成内容时出错: {e}"
58-
print(error_message)
59-
yield "抱歉,我在生成回答时遇到了一些问题。"
9+
super().__init__(model_name=model_name, provider="grok")

src/providers/jina.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def rerank(self, query: str, documents: List[str], top_n: int) -> Tuple[List[int
7878
return indices, scores
7979
except Exception as e:
8080
logger.error(f"Jina Rerank ({self._model_name}) 出错: {e}", exc_info=True)
81-
return list(range(len(documents))), [0.0] * len(documents)
81+
raise
8282

8383
@retry(
8484
stop=stop_after_attempt(3),
@@ -108,4 +108,4 @@ async def arerank(self, query: str, documents: List[str], top_n: int) -> Tuple[L
108108
return indices, scores
109109
except Exception as e:
110110
logger.error(f"Jina Rerank ({self._model_name}) 异步出错: {e}", exc_info=True)
111-
return list(range(len(documents))), [0.0] * len(documents)
111+
raise

src/providers/openai.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def invoke(
8484
logger.info(f"OpenAI LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s")
8585
except Exception as e:
8686
logger.error(f"OpenAI LLM ({self._model_name}) 出错: {e}", exc_info=True)
87-
yield f"抱歉,OpenAI 遇到错误: {str(e)}"
87+
raise
8888

8989
@retry(
9090
stop=stop_after_attempt(3),
@@ -126,7 +126,7 @@ async def ainvoke(
126126
logger.info(f"OpenAI LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s")
127127
except Exception as e:
128128
logger.error(f"OpenAI LLM ({self._model_name}) 异步出错: {e}", exc_info=True)
129-
yield f"抱歉,OpenAI 异步处理遇到错误: {str(e)}"
129+
raise
130130

131131
@retry(
132132
stop=stop_after_attempt(3),
@@ -148,7 +148,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
148148
return embeddings
149149
except Exception as e:
150150
logger.error(f"OpenAI Embedding ({self._model_name}) 出错: {e}", exc_info=True)
151-
return [[] for _ in texts]
151+
raise
152152

153153
@retry(
154154
stop=stop_after_attempt(3),
@@ -170,4 +170,4 @@ async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
170170
return embeddings
171171
except Exception as e:
172172
logger.error(f"OpenAI Embedding ({self._model_name}) 异步出错: {e}", exc_info=True)
173-
return [[] for _ in texts]
173+
raise

src/providers/openai_compatible.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def invoke(
9292
logger.info(f"{self._provider} LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s")
9393
except Exception as e:
9494
logger.error(f"{self._provider} LLM ({self._model_name}) 出错: {e}", exc_info=True)
95-
yield f"抱歉,提供商 {self._provider} 遇到错误: {str(e)}"
95+
raise
9696

9797
async def ainvoke(
9898
self,
@@ -128,7 +128,7 @@ async def ainvoke(
128128
logger.info(f"{self._provider} LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s")
129129
except Exception as e:
130130
logger.error(f"{self._provider} LLM ({self._model_name}) 异步出错: {e}", exc_info=True)
131-
yield f"抱歉,提供商 {self._provider} 异步处理遇到错误: {str(e)}"
131+
raise
132132

133133
@retry(
134134
stop=stop_after_attempt(3),
@@ -150,7 +150,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
150150
return embeddings
151151
except Exception as e:
152152
logger.error(f"{self._provider} 嵌入 ({self._model_name}) 出错: {e}", exc_info=True)
153-
return [[] for _ in texts]
153+
raise
154154

155155
async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
156156
"""异步向量化文档。"""
@@ -166,4 +166,4 @@ async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
166166
return embeddings
167167
except Exception as e:
168168
logger.error(f"{self._provider} 嵌入 ({self._model_name}) 异步出错: {e}", exc_info=True)
169-
return [[] for _ in texts]
169+
raise

src/providers/siliconflow_rerank.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def rerank(self, query: str, documents: List[str], top_n: int) -> Tuple[List[int
8484
return indices, scores
8585
except Exception as e:
8686
logger.error(f"SiliconFlow Rerank ({self._model_name}) 出错: {e}", exc_info=True)
87-
return list(range(len(documents))), [0.0] * len(documents)
87+
raise
8888

8989
@retry(
9090
stop=stop_after_attempt(3),
@@ -115,4 +115,4 @@ async def arerank(self, query: str, documents: List[str], top_n: int) -> Tuple[L
115115
return indices, scores
116116
except Exception as e:
117117
logger.error(f"SiliconFlow Rerank ({self._model_name}) 异步出错: {e}", exc_info=True)
118-
return list(range(len(documents))), [0.0] * len(documents)
118+
raise

src/providers/volcengine.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import time
22
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional
33

4-
from volcengine.ark import Ark, AsyncArk
4+
from volcenginesdkarkruntime import Ark, AsyncArk
55
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
66

77
from src.providers.__base__.model_provider import (
@@ -82,7 +82,7 @@ def invoke(
8282
logger.info(f"火山引擎 LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s")
8383
except Exception as e:
8484
logger.error(f"火山引擎 LLM ({self._model_name}) 出错: {e}", exc_info=True)
85-
yield f"抱歉,火山引擎遇到错误: {str(e)}"
85+
raise
8686

8787
@retry(
8888
stop=stop_after_attempt(3),
@@ -124,7 +124,7 @@ async def ainvoke(
124124
logger.info(f"火山引擎 LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s")
125125
except Exception as e:
126126
logger.error(f"火山引擎 LLM ({self._model_name}) 异步出错: {e}", exc_info=True)
127-
yield f"抱歉,火山引擎异步处理遇到错误: {str(e)}"
127+
raise
128128

129129
@retry(
130130
stop=stop_after_attempt(3),
@@ -147,7 +147,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
147147
return embeddings
148148
except Exception as e:
149149
logger.error(f"火山引擎嵌入 ({self._model_name}) 出错: {e}", exc_info=True)
150-
return [[] for _ in texts]
150+
raise
151151

152152
@retry(
153153
stop=stop_after_attempt(3),
@@ -169,4 +169,4 @@ async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
169169
return embeddings
170170
except Exception as e:
171171
logger.error(f"火山引擎嵌入 ({self._model_name}) 异步出错: {e}", exc_info=True)
172-
return [[] for _ in texts]
172+
raise

src/utils/config.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,14 @@ def split_separators(cls, v: Any) -> List[str]:
197197
如果分隔符是字符串,则按逗号分割成列表。
198198
如果输入值为空(None或空字符串),或者分割后为空列表,则使用字段的默认值。
199199
"""
200-
# 通过访问类的模型字段来安全地获取默认值
201-
default_value = cls.model_fields['kb_splitter_separators'].default
202-
if default_value is None:
203-
default_value = [] # 以防万一没有设置默认值
200+
field_info = cls.model_fields["kb_splitter_separators"]
201+
default_factory = field_info.default_factory
202+
if default_factory is not None:
203+
default_value = default_factory()
204+
else:
205+
default_value = field_info.default
206+
if default_value is None:
207+
default_value = []
204208

205209
# 如果输入为空(来自 .env 或环境变量的空字符串),则回退到默认值
206210
if v is None or v == '':
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# -*- coding: utf-8 -*-
2+
import asyncio
3+
import importlib
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
import src.providers.factory as factory_module
9+
from src.providers.factory import ModelProviderFactory
10+
from src.providers.grok import GrokProvider
11+
from src.providers.openai import OpenAIProvider
12+
from src.providers.openai_compatible import OpenAICompatibleProvider
13+
from src.utils.config import ModelDetail
14+
15+
16+
class FailingAsyncClient:
17+
class chat:
18+
class completions:
19+
@staticmethod
20+
async def create(*_args, **_kwargs):
21+
raise RuntimeError("boom-chat")
22+
23+
class embeddings:
24+
@staticmethod
25+
async def create(*_args, **_kwargs):
26+
raise RuntimeError("boom-embed")
27+
28+
29+
def test_openai_provider_async_failure_raises(monkeypatch):
30+
fake_settings = SimpleNamespace(openai_api_key="token", openai_api_base="http://example.com")
31+
monkeypatch.setattr("src.providers.openai.get_settings", lambda: fake_settings)
32+
33+
provider = OpenAIProvider("demo")
34+
provider._aclient = FailingAsyncClient()
35+
36+
async def consume() -> None:
37+
async for _chunk in provider.ainvoke("hello", stream=False):
38+
pass
39+
40+
with pytest.raises(RuntimeError, match="boom-chat"):
41+
asyncio.run(consume())
42+
43+
with pytest.raises(RuntimeError, match="boom-embed"):
44+
asyncio.run(provider.aembed_documents(["doc"]))
45+
46+
47+
def test_openai_compatible_provider_async_failure_raises(monkeypatch):
48+
fake_settings = SimpleNamespace(deepseek_api_key="token", deepseek_base_url="http://example.com")
49+
monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings)
50+
51+
provider = OpenAICompatibleProvider("demo", "deepseek")
52+
provider._aclient = FailingAsyncClient()
53+
54+
async def consume() -> None:
55+
async for _chunk in provider.ainvoke("hello", stream=False):
56+
pass
57+
58+
with pytest.raises(RuntimeError, match="boom-chat"):
59+
asyncio.run(consume())
60+
61+
with pytest.raises(RuntimeError, match="boom-embed"):
62+
asyncio.run(provider.aembed_documents(["doc"]))
63+
64+
65+
def test_grok_provider_can_be_loaded_by_factory(monkeypatch):
66+
fake_settings = SimpleNamespace(
67+
llm_configurations={"grok-test": ModelDetail(provider="grok", model_name="grok-1")},
68+
grok_api_key="token",
69+
grok_base_url="https://api.x.ai/v1",
70+
)
71+
monkeypatch.setattr(factory_module, "get_settings", lambda: fake_settings)
72+
monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings)
73+
74+
provider = factory_module.ModelProviderFactory.get_llm_provider("grok-test")
75+
76+
assert provider.__class__.__name__ == GrokProvider.__name__
77+
assert provider.__class__.__module__ == "src.providers.grok"
78+
79+
80+
@pytest.mark.parametrize(
81+
("provider_name", "module_name", "class_name"),
82+
[
83+
(provider_name, provider_info["module"], provider_info["class"])
84+
for provider_name, provider_info in ModelProviderFactory._provider_map.items()
85+
],
86+
)
87+
def test_provider_map_modules_are_importable(provider_name, module_name, class_name):
88+
module = importlib.import_module(module_name)
89+
provider_class = getattr(module, class_name)
90+
91+
assert provider_class is not None, provider_name

0 commit comments

Comments
 (0)