appQQQ commited on
Commit
994df8e
·
verified ·
1 Parent(s): 2ece133

chore: upload app/llm/minimax.py

Browse files
Files changed (1) hide show
  1. app/llm/minimax.py +239 -0
app/llm/minimax.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax-M3 provider (OpenAI 兼容协议).
2
+
3
+ 通过 AsyncOpenAI(base_url=...) 调用 MiniMax 端点.
4
+ 所有 OpenAI 兼容的 provider (Qwen / DeepSeek / 自部署 vLLM 等) 都可以复用本类,
5
+ 只需换 base_url 和 model.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from typing import Any
12
+
13
+ from openai import APIError, APITimeoutError, AsyncOpenAI, RateLimitError
14
+ from tenacity import (
15
+ retry,
16
+ retry_if_exception_type,
17
+ stop_after_attempt,
18
+ wait_exponential,
19
+ )
20
+
21
+ from app.config import settings
22
+ from app.core.errors import LLMUnavailableError
23
+ from app.llm.base import (
24
+ AbstractLLM,
25
+ LLMChunk,
26
+ LLMMessage,
27
+ LLMResponse,
28
+ ToolSpec,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _to_openai_messages(messages: list[LLMMessage]) -> list[dict[str, Any]]:
35
+ """转换为本类消息 -> OpenAI ChatMessage dict."""
36
+ out: list[dict[str, Any]] = []
37
+ for m in messages:
38
+ d: dict[str, Any] = {"role": m.role, "content": m.content}
39
+ if m.name:
40
+ d["name"] = m.name
41
+ if m.tool_call_id:
42
+ d["tool_call_id"] = m.tool_call_id
43
+ if m.tool_calls:
44
+ d["tool_calls"] = m.tool_calls
45
+ out.append(d)
46
+ return out
47
+
48
+
49
+ def _to_openai_tools(tools: list[ToolSpec]) -> list[dict[str, Any]]:
50
+ """ToolSpec -> OpenAI tools format."""
51
+ return [
52
+ {
53
+ "type": "function",
54
+ "function": {
55
+ "name": t.name,
56
+ "description": t.description,
57
+ "parameters": t.parameters,
58
+ },
59
+ }
60
+ for t in tools
61
+ ]
62
+
63
+
64
+ class OpenAICompatibleLLM(AbstractLLM):
65
+ """OpenAI 兼容协议的通用 LLM (MiniMax / Qwen / DeepSeek / 自部署 vLLM).
66
+
67
+ 之所以不叫 MiniMaxLLM: 因为实现是通用的, 通过 (base_url, model) 切换 provider.
68
+ factory.py 用 settings 决定具体配置.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ api_key: str,
75
+ base_url: str,
76
+ model: str,
77
+ provider_name: str = "openai-compat",
78
+ timeout: float = 60.0,
79
+ ) -> None:
80
+ self.name = provider_name
81
+ self.model = model
82
+ # 关键: trust_env=False 强制不走系统代理 (urllib.getproxies() / macOS System Preferences)
83
+ # 否则 httpx 会尝试走 127.0.0.1:7897, 若该端口代理服务不可用, TLS 握手会挂在
84
+ # start_tls 阶段报 "Connection error"
85
+ import httpx as _httpx
86
+ _http_client = _httpx.AsyncClient(
87
+ trust_env=False,
88
+ timeout=timeout,
89
+ )
90
+ self.client = AsyncOpenAI(
91
+ api_key=api_key or "EMPTY",
92
+ base_url=base_url,
93
+ timeout=timeout,
94
+ max_retries=0, # tenacity 自管
95
+ http_client=_http_client,
96
+ )
97
+ logger.info(
98
+ "LLM client init: provider=%s base_url=%s model=%s (trust_env=False, no proxy)",
99
+ provider_name, base_url, model,
100
+ )
101
+
102
+ async def aclose(self) -> None:
103
+ await self.client.close()
104
+
105
+ @retry(
106
+ retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIError)),
107
+ stop=stop_after_attempt(3),
108
+ wait=wait_exponential(multiplier=1, min=1, max=10),
109
+ reraise=True,
110
+ )
111
+ async def chat(
112
+ self,
113
+ messages: list[LLMMessage],
114
+ *,
115
+ tools: list[ToolSpec] | None = None,
116
+ temperature: float = 0.7,
117
+ max_tokens: int | None = None,
118
+ **kwargs: Any,
119
+ ) -> LLMResponse:
120
+ params: dict[str, Any] = {
121
+ "model": self.model,
122
+ "messages": _to_openai_messages(messages),
123
+ "temperature": temperature,
124
+ }
125
+ if max_tokens is not None:
126
+ params["max_tokens"] = max_tokens
127
+ if tools:
128
+ params["tools"] = _to_openai_tools(tools)
129
+ params.update(kwargs)
130
+
131
+ try:
132
+ resp = await self.client.chat.completions.create(**params)
133
+ except (APITimeoutError, RateLimitError, APIError) as e:
134
+ logger.warning("LLM chat retry: %s", e)
135
+ raise
136
+ except Exception as e:
137
+ raise LLMUnavailableError(
138
+ f"LLM call failed: {e}", retryable=False
139
+ ) from e
140
+
141
+ choice = resp.choices[0]
142
+ msg = choice.message
143
+ usage = (
144
+ {
145
+ "prompt_tokens": resp.usage.prompt_tokens,
146
+ "completion_tokens": resp.usage.completion_tokens,
147
+ "total_tokens": resp.usage.total_tokens,
148
+ }
149
+ if resp.usage
150
+ else {}
151
+ )
152
+ return LLMResponse(
153
+ content=msg.content or "",
154
+ tool_calls=[tc.model_dump() for tc in (msg.tool_calls or [])],
155
+ finish_reason=choice.finish_reason or "stop",
156
+ usage=usage,
157
+ raw=resp,
158
+ )
159
+
160
+ @retry(
161
+ retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIError)),
162
+ stop=stop_after_attempt(3),
163
+ wait=wait_exponential(multiplier=1, min=1, max=10),
164
+ reraise=True,
165
+ )
166
+ async def stream_chat(
167
+ self,
168
+ messages: list[LLMMessage],
169
+ *,
170
+ tools: list[ToolSpec] | None = None,
171
+ temperature: float = 0.7,
172
+ max_tokens: int | None = None,
173
+ **kwargs: Any,
174
+ ) -> AsyncIterator[LLMChunk]:
175
+ params: dict[str, Any] = {
176
+ "model": self.model,
177
+ "messages": _to_openai_messages(messages),
178
+ "temperature": temperature,
179
+ "stream": True,
180
+ "stream_options": {"include_usage": True},
181
+ }
182
+ if max_tokens is not None:
183
+ params["max_tokens"] = max_tokens
184
+ if tools:
185
+ params["tools"] = _to_openai_tools(tools)
186
+ params.update(kwargs)
187
+
188
+ try:
189
+ stream = await self.client.chat.completions.create(**params)
190
+ async for ev in stream:
191
+ if not ev.choices:
192
+ # 最终 usage chunk (choices 为空, 只有 usage)
193
+ if getattr(ev, "usage", None):
194
+ yield LLMChunk(
195
+ content="",
196
+ finish_reason="stop",
197
+ usage={
198
+ "prompt_tokens": ev.usage.prompt_tokens,
199
+ "completion_tokens": ev.usage.completion_tokens,
200
+ "total_tokens": ev.usage.total_tokens,
201
+ },
202
+ )
203
+ continue
204
+ choice = ev.choices[0]
205
+ delta = choice.delta
206
+ tc_dumps: list[dict[str, Any]] = []
207
+ if delta.tool_calls:
208
+ for tc in delta.tool_calls:
209
+ tc_dumps.append(tc.model_dump(exclude_unset=True))
210
+ yield LLMChunk(
211
+ content=delta.content or "",
212
+ tool_calls=tc_dumps,
213
+ finish_reason=choice.finish_reason,
214
+ )
215
+ except (APITimeoutError, RateLimitError, APIError) as e:
216
+ logger.warning("LLM stream retry: %s", e)
217
+ raise
218
+ except Exception as e:
219
+ raise LLMUnavailableError(
220
+ f"LLM stream failed: {e}", retryable=False
221
+ ) from e
222
+
223
+ async def health_check(self) -> bool:
224
+ """极简探活: 1 token 补全."""
225
+ try:
226
+ resp = await self.client.chat.completions.create(
227
+ model=self.model,
228
+ messages=[{"role": "user", "content": "ping"}],
229
+ max_tokens=1,
230
+ temperature=0,
231
+ )
232
+ return bool(resp.choices)
233
+ except Exception as e: # noqa: BLE001
234
+ logger.warning("LLM health check failed: %s", e)
235
+ return False
236
+
237
+
238
+ # 向后兼容别名: 早期代码可能用 MiniMaxLLM
239
+ MiniMaxLLM = OpenAICompatibleLLM