springbootAI 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. spring/__init__.py +66 -0
  2. spring/ai/__init__.py +78 -0
  3. spring/ai/advisors.py +139 -0
  4. spring/ai/annotations.py +74 -0
  5. spring/ai/autoconfig.py +481 -0
  6. spring/ai/core.py +391 -0
  7. spring/ai/etl.py +188 -0
  8. spring/ai/memory.py +109 -0
  9. spring/ai/observability.py +129 -0
  10. spring/ai/providers.py +789 -0
  11. spring/ai/resilience.py +258 -0
  12. spring/ai/tools.py +106 -0
  13. spring/ai/vectorstore.py +303 -0
  14. spring/annotations/__init__.py +188 -0
  15. spring/annotations/cache.py +126 -0
  16. spring/annotations/cloud.py +207 -0
  17. spring/annotations/conditional.py +272 -0
  18. spring/annotations/core.py +864 -0
  19. spring/annotations/messaging.py +107 -0
  20. spring/aop/__init__.py +4 -0
  21. spring/aop/cloud_aop.py +404 -0
  22. spring/aop/comprehensive_aop.py +1015 -0
  23. spring/aop/method_interceptor.py +19 -0
  24. spring/aop/proxy_factory.py +55 -0
  25. spring/cloud/__init__.py +76 -0
  26. spring/cloud/discovery.py +364 -0
  27. spring/cloud/feign.py +469 -0
  28. spring/cloud/gateway.py +452 -0
  29. spring/cloud/load_balancer.py +149 -0
  30. spring/cloud/seata.py +557 -0
  31. spring/cloud/sentinel.py +525 -0
  32. spring/cloud/tracer.py +337 -0
  33. spring/config/__init__.py +21 -0
  34. spring/config/binding.py +206 -0
  35. spring/config/config_loader.py +405 -0
  36. spring/context/__init__.py +13 -0
  37. spring/context/application_context.py +589 -0
  38. spring/context/bean_definition.py +70 -0
  39. spring/context/bean_factory.py +1052 -0
  40. spring/context/registry.py +58 -0
  41. spring/context/scanner.py +106 -0
  42. spring/core/__init__.py +3 -0
  43. spring/core/graceful_shutdown.py +196 -0
  44. spring/core/typing_utils.py +50 -0
  45. spring/csv/__init__.py +52 -0
  46. spring/csv/annotations.py +402 -0
  47. spring/csv/converters.py +69 -0
  48. spring/csv/easy_csv.py +95 -0
  49. spring/csv/exceptions.py +27 -0
  50. spring/csv/reader.py +195 -0
  51. spring/csv/writer.py +155 -0
  52. spring/data/__init__.py +54 -0
  53. spring/data/page.py +181 -0
  54. spring/data/repository.py +274 -0
  55. spring/data/specification.py +228 -0
  56. spring/datasource/__init__.py +66 -0
  57. spring/datasource/annotations.py +133 -0
  58. spring/datasource/context.py +69 -0
  59. spring/datasource/dynamic.py +148 -0
  60. spring/event/__init__.py +7 -0
  61. spring/event/publisher.py +69 -0
  62. spring/excel/__init__.py +51 -0
  63. spring/excel/annotations.py +405 -0
  64. spring/excel/converters.py +231 -0
  65. spring/excel/easy_excel.py +94 -0
  66. spring/excel/exceptions.py +31 -0
  67. spring/excel/reader.py +254 -0
  68. spring/excel/style.py +95 -0
  69. spring/excel/writer.py +197 -0
  70. spring/i18n/__init__.py +97 -0
  71. spring/i18n/accessor.py +94 -0
  72. spring/i18n/auto_config.py +177 -0
  73. spring/i18n/holder.py +106 -0
  74. spring/i18n/locale.py +152 -0
  75. spring/i18n/locale_resolver.py +367 -0
  76. spring/i18n/message_source.py +250 -0
  77. spring/i18n/middleware.py +79 -0
  78. spring/i18n/properties.py +168 -0
  79. spring/i18n/sources.py +255 -0
  80. spring/logging/__init__.py +1 -0
  81. spring/logging/loguru_logger.py +228 -0
  82. spring/main.py +378 -0
  83. spring/messaging/__init__.py +1 -0
  84. spring/messaging/rabbitmq.py +302 -0
  85. spring/monitoring/__init__.py +1 -0
  86. spring/monitoring/prometheus.py +199 -0
  87. spring/orm/__init__.py +258 -0
  88. spring/orm/database.py +222 -0
  89. spring/orm/ddl_auto.py +1217 -0
  90. spring/orm/migration.py +419 -0
  91. spring/orm/mybatis_integration.py +400 -0
  92. spring/orm/pymybatis/__init__.py +86 -0
  93. spring/orm/pymybatis/annotations/__init__.py +30 -0
  94. spring/orm/pymybatis/annotations/annotations.py +332 -0
  95. spring/orm/pymybatis/cache/__init__.py +47 -0
  96. spring/orm/pymybatis/cache/cache.py +371 -0
  97. spring/orm/pymybatis/cache/redis_cache.py +434 -0
  98. spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
  99. spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
  100. spring/orm/pymybatis/configuration.py +525 -0
  101. spring/orm/pymybatis/core/__init__.py +10 -0
  102. spring/orm/pymybatis/core/sql_session.py +1382 -0
  103. spring/orm/pymybatis/core/sql_session_factory.py +76 -0
  104. spring/orm/pymybatis/dialect/__init__.py +9 -0
  105. spring/orm/pymybatis/dialect/dialect.py +445 -0
  106. spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
  107. spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
  108. spring/orm/pymybatis/interceptor/__init__.py +31 -0
  109. spring/orm/pymybatis/interceptor/interceptor.py +427 -0
  110. spring/orm/pymybatis/mapper/__init__.py +9 -0
  111. spring/orm/pymybatis/mapper/mapper.py +540 -0
  112. spring/orm/pymybatis/metrics/__init__.py +41 -0
  113. spring/orm/pymybatis/metrics/metrics.py +595 -0
  114. spring/orm/pymybatis/pool/__init__.py +9 -0
  115. spring/orm/pymybatis/pool/connection_pool.py +711 -0
  116. spring/orm/pymybatis/security/__init__.py +19 -0
  117. spring/orm/pymybatis/security/access_control.py +415 -0
  118. spring/orm/pymybatis/security/password_encoder.py +293 -0
  119. spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
  120. spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
  121. spring/orm/pymybatis/transaction/__init__.py +9 -0
  122. spring/orm/pymybatis/transaction/transaction.py +288 -0
  123. spring/orm/pymybatis/type_handler/__init__.py +37 -0
  124. spring/orm/pymybatis/type_handler/type_handler.py +473 -0
  125. spring/orm/pymybatis/version.py +9 -0
  126. spring/orm/pymybatis/xml_parser/__init__.py +9 -0
  127. spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
  128. spring/retry/__init__.py +12 -0
  129. spring/retry/retry_annotations.py +71 -0
  130. spring/retry/retry_decorator.py +155 -0
  131. spring/scheduling/__init__.py +3 -0
  132. spring/scheduling/scheduler.py +389 -0
  133. spring/security/__init__.py +39 -0
  134. spring/security/jwt_utils.py +281 -0
  135. spring/security/replay_protection.py +206 -0
  136. spring/security/secret_manager.py +226 -0
  137. spring/security/security_aop.py +248 -0
  138. spring/security/security_context.py +172 -0
  139. spring/test/__init__.py +45 -0
  140. spring/test/slicing.py +341 -0
  141. spring/tracing/__init__.py +11 -0
  142. spring/tracing/skywalking.py +229 -0
  143. spring/tx/__init__.py +52 -0
  144. spring/tx/events.py +172 -0
  145. spring/tx/synchronization.py +143 -0
  146. spring/utils/__init__.py +5 -0
  147. spring/utils/banner.py +32 -0
  148. spring/utils/logger.py +73 -0
  149. spring/utils/redis_client.py +526 -0
  150. spring/validation/__init__.py +55 -0
  151. spring/validation/aop.py +141 -0
  152. spring/validation/constraints.py +357 -0
  153. spring/validation/exceptions.py +55 -0
  154. spring/validation/validator.py +139 -0
  155. spring/web/__init__.py +12 -0
  156. spring/web/actuator.py +319 -0
  157. spring/web/exception_handler.py +61 -0
  158. spring/web/health.py +399 -0
  159. spring/web/interceptor.py +91 -0
  160. spring/web/result.py +44 -0
  161. spring/web/swagger.py +601 -0
  162. spring/web/web_context.py +755 -0
  163. spring/websocket/__init__.py +86 -0
  164. spring/websocket/annotations.py +169 -0
  165. spring/websocket/broker.py +238 -0
  166. spring/websocket/exceptions.py +26 -0
  167. spring/websocket/handler.py +243 -0
  168. spring/websocket/router.py +526 -0
  169. spring/websocket/session.py +216 -0
  170. springbootai-1.8.0.dist-info/METADATA +2796 -0
  171. springbootai-1.8.0.dist-info/RECORD +175 -0
  172. springbootai-1.8.0.dist-info/WHEEL +5 -0
  173. springbootai-1.8.0.dist-info/entry_points.txt +2 -0
  174. springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
  175. springbootai-1.8.0.dist-info/top_level.txt +1 -0
spring/ai/providers.py ADDED
@@ -0,0 +1,789 @@
1
+ """
2
+ 模型 Provider 适配层 - OpenAI 兼容 + Ollama(企业级)。
3
+
4
+ 已落地的企业能力:
5
+ 1. 函数调用闭环 - tools 注入请求体,tool_call 解析→执行→回填→续写循环
6
+ 2. 真流式 SSE - stream=True 解析 data: 增量,逐块 yield
7
+ 3. async - acall/astream 异步入口
8
+ 4. 韧性 - 复用 spring.retry 重试 + AICircuitBreaker 熔断
9
+ 5. 可观测 - ai_metrics 记录调用/token/延迟
10
+
11
+ 底层优先复用 LangChain 生态(langchain_openai/langchain_community)做模型适配,
12
+ 未安装时降级原生 HTTP(requests),保证开箱即用。
13
+ """
14
+ import json
15
+ import logging
16
+ import time
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from spring.ai.core import (
20
+ ChatModel, ChatResponse, EmbeddingModel, Generation, Message, MessageType,
21
+ )
22
+
23
+ logger = logging.getLogger("Spring.AI")
24
+
25
+
26
+ def _has_langchain_openai() -> bool:
27
+ try:
28
+ import langchain_openai # noqa: F401
29
+ return True
30
+ except ImportError:
31
+ return False
32
+
33
+
34
+ def _has_langchain_community() -> bool:
35
+ try:
36
+ import langchain_community # noqa: F401
37
+ return True
38
+ except ImportError:
39
+ return False
40
+
41
+
42
+ def _has_langchain(module: str) -> bool:
43
+ """按模块名探测是否安装了某个 langchain-* 包。"""
44
+ try:
45
+ __import__(module)
46
+ return True
47
+ except ImportError:
48
+ return False
49
+
50
+
51
+ def _is_tool_registry(obj) -> bool:
52
+ return obj is not None and hasattr(obj, "schemas") and hasattr(obj, "execute")
53
+
54
+
55
+ # 瞬态 HTTP 状态码:网络抖动/限流/服务端瞬态故障应触发重试与熔断
56
+ _TRANSIENT_STATUS = (429, 500, 502, 503, 504)
57
+
58
+
59
+ def _http_post_json(url, *, json_body, headers=None, timeout,
60
+ max_retries, retry_delay_ms, circuit_breaker, provider):
61
+ """
62
+ 统一 HTTP POST + 重试 + 熔断(DRY 重构)。
63
+
64
+ 将网络连接失败/超时/429/5xx 归类为瞬态(TransientError → 重试 + 熔断计数),
65
+ 其余错误(如 401/403/400 鉴权或参数错误)原样抛出,不做无意义重试。
66
+
67
+ 修复:此前各 Provider 的 HTTP 调用逻辑重复且瞬态分类不一致
68
+ (OllamaEmbeddingModel 漏掉 HTTPError 的 429/5xx 归类,导致瞬态不重试;
69
+ OpenAI 流式对 401/403 也重试,浪费并掩盖真实错误)。
70
+ """
71
+ from spring.ai.resilience import resilient_call, TransientError
72
+ import requests
73
+
74
+ def _do_post():
75
+ try:
76
+ resp = requests.post(url, json=json_body, headers=headers,
77
+ timeout=timeout)
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+ except (requests.ConnectionError, requests.Timeout) as exc:
81
+ raise TransientError(str(exc)) from exc
82
+ except requests.HTTPError as exc:
83
+ if resp.status_code in _TRANSIENT_STATUS:
84
+ raise TransientError(str(exc)) from exc
85
+ raise
86
+
87
+ return resilient_call(
88
+ _do_post, max_retries=max_retries, retry_delay_ms=retry_delay_ms,
89
+ retry_exceptions=(TransientError,), circuit_breaker=circuit_breaker,
90
+ count_as_failure_exc=(TransientError,), provider=provider,
91
+ )()
92
+
93
+
94
+ def _is_transient_http_exc(exc, resp) -> bool:
95
+ """判断流式场景下异常是否为瞬态(连接/超时/429/5xx)。"""
96
+ import requests
97
+ if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
98
+ return True
99
+ if isinstance(exc, requests.HTTPError):
100
+ return getattr(resp, "status_code", None) in _TRANSIENT_STATUS
101
+ return False
102
+
103
+
104
+ # ==================== OpenAI 兼容 ====================
105
+
106
+ class OpenAIChatModel(ChatModel):
107
+ """
108
+ OpenAI 兼容聊天模型 - 支持 OpenAI / Azure / DeepSeek / Moonshot 等兼容接口。
109
+
110
+ 企业能力:函数调用闭环 / 真流式 / 重试 / 熔断 / Prometheus 观测。
111
+ """
112
+
113
+ # 函数调用最大往返轮数(防止模型无限调用工具)
114
+ MAX_TOOL_ITERATIONS = 5
115
+
116
+ def __init__(self, api_key: str = "", base_url: str = "",
117
+ model: str = "gpt-4o-mini", temperature: float = 0.7,
118
+ timeout: int = 60,
119
+ max_retries: int = 3, retry_delay_ms: int = 500,
120
+ circuit_breaker=None):
121
+ self.api_key = api_key
122
+ self.base_url = base_url.rstrip("/") if base_url else "https://api.openai.com/v1"
123
+ self.model = model
124
+ self.temperature = temperature
125
+ self.timeout = timeout
126
+ self.max_retries = max_retries
127
+ self.retry_delay_ms = retry_delay_ms
128
+ self.circuit_breaker = circuit_breaker
129
+ self._llm = None
130
+ if _has_langchain_openai() and api_key:
131
+ try:
132
+ from langchain_openai import ChatOpenAI
133
+ self._llm = ChatOpenAI(
134
+ api_key=api_key,
135
+ base_url=self.base_url if base_url else None,
136
+ model=model, temperature=temperature, timeout=timeout,
137
+ )
138
+ except Exception as exc: # pragma: no cover
139
+ logger.warning("ChatOpenAI 初始化失败,降级原生HTTP: %s", exc)
140
+ self._llm = None
141
+
142
+ # ---------- 公共入口 ----------
143
+
144
+ def call(self, messages: List[Message],
145
+ tool_registry=None,
146
+ options: Optional[Dict[str, Any]] = None) -> ChatResponse:
147
+ """同步调用(基类闭环 + 整体指标)"""
148
+ from spring.ai.observability import ai_metrics
149
+ start = time.time()
150
+ try:
151
+ resp = super().call(messages, tool_registry, options)
152
+ usage = (resp.metadata or {}).get("usage") if resp else None
153
+ ai_metrics.record_call("openai", self.model, "success",
154
+ time.time() - start, usage)
155
+ return resp
156
+ except Exception:
157
+ ai_metrics.record_call("openai", self.model, "failure",
158
+ time.time() - start)
159
+ raise
160
+
161
+ async def acall(self, messages: List[Message],
162
+ tool_registry=None,
163
+ options: Optional[Dict[str, Any]] = None) -> ChatResponse:
164
+ import asyncio
165
+ return await asyncio.to_thread(self.call, messages, tool_registry, options)
166
+
167
+ def stream(self, messages: List[Message],
168
+ tool_registry=None,
169
+ options: Optional[Dict[str, Any]] = None):
170
+ """真流式 - SSE 增量生成器"""
171
+ if self._llm is not None:
172
+ yield from self._stream_via_langchain(messages, options)
173
+ return
174
+ yield from self._stream_via_http(messages, options)
175
+
176
+ async def astream(self, messages: List[Message],
177
+ tool_registry=None,
178
+ options: Optional[Dict[str, Any]] = None):
179
+ import asyncio
180
+ loop = asyncio.get_event_loop()
181
+ queue: asyncio.Queue = asyncio.Queue()
182
+
183
+ def _producer():
184
+ for chunk in self.stream(messages, tool_registry, options):
185
+ asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
186
+ asyncio.run_coroutine_threadsafe(queue.put(None), loop)
187
+
188
+ loop.run_in_executor(None, _producer)
189
+ while True:
190
+ chunk = await queue.get()
191
+ if chunk is None:
192
+ break
193
+ yield chunk
194
+
195
+ # ---------- Provider 单次调用 ----------
196
+
197
+ def _raw_call(self, messages, tool_registry=None, options=None) -> ChatResponse:
198
+ if self._llm is not None:
199
+ return self._call_via_langchain(messages, options)
200
+ return self._call_via_http(messages, tool_registry, options)
201
+
202
+ def _call_via_langchain(self, messages, options) -> ChatResponse:
203
+ lc_messages = [(m.type, m.content) for m in messages]
204
+ result = self._llm.invoke(lc_messages)
205
+ content = result.content if hasattr(result, "content") else str(result)
206
+ usage = getattr(result, "usage_metadata", None) or {}
207
+ return ChatResponse(
208
+ generations=[Generation(output=Message.assistant(content))],
209
+ metadata={"provider": "openai", "backend": "langchain", "usage": usage},
210
+ )
211
+
212
+ def _stream_via_langchain(self, messages, options):
213
+ if self._llm is None:
214
+ return
215
+ for chunk in self._llm.stream([(m.type, m.content) for m in messages]):
216
+ content = chunk.content if hasattr(chunk, "content") else str(chunk)
217
+ if content:
218
+ yield ChatResponse(generations=[Generation(
219
+ output=Message.assistant(content))],
220
+ metadata={"provider": "openai", "stream": True})
221
+
222
+ def _call_via_http(self, messages, tool_registry, options) -> ChatResponse:
223
+ """单次 HTTP 调用 - 注入 tools schema,解析 tool_calls 到 metadata"""
224
+ payload = {
225
+ "model": self.model,
226
+ "messages": [self._serialize_msg(m) for m in messages],
227
+ "temperature": self.temperature,
228
+ }
229
+ if options:
230
+ payload.update(options)
231
+ # 注入 tools schema(基类闭环依赖此)
232
+ if _is_tool_registry(tool_registry) and tool_registry.names():
233
+ payload["tools"] = tool_registry.schemas()
234
+
235
+ data = _http_post_json(
236
+ f"{self.base_url}/chat/completions",
237
+ json_body=payload,
238
+ headers={"Authorization": f"Bearer {self.api_key}",
239
+ "Content-Type": "application/json"},
240
+ timeout=self.timeout,
241
+ max_retries=self.max_retries,
242
+ retry_delay_ms=self.retry_delay_ms,
243
+ circuit_breaker=self.circuit_breaker,
244
+ provider="openai",
245
+ )
246
+
247
+ choice = data.get("choices", [{}])[0]
248
+ msg_obj = choice.get("message", {})
249
+ tool_calls = msg_obj.get("tool_calls")
250
+ usage = data.get("usage", {})
251
+ content = msg_obj.get("content", "") or ""
252
+ meta = {"provider": "openai", "backend": "http", "usage": usage}
253
+ if tool_calls:
254
+ # 标记 tool_calls,由基类 call() 闭环执行;assistant 消息保留 tool_calls 元数据以便重发
255
+ meta["tool_calls"] = tool_calls
256
+ return ChatResponse(
257
+ generations=[Generation(output=Message(
258
+ content=content, type=MessageType.ASSISTANT,
259
+ metadata={"tool_calls": tool_calls or []}))],
260
+ metadata=meta,
261
+ )
262
+
263
+ def _stream_via_http(self, messages, options):
264
+ """真流式:解析 SSE data: 行,逐块 yield 增量内容(含网络中断重试降级)"""
265
+ import requests
266
+ import time as _time
267
+ payload = {
268
+ "model": self.model,
269
+ "messages": [self._serialize_msg(m) for m in messages],
270
+ "temperature": self.temperature,
271
+ "stream": True,
272
+ }
273
+ if options:
274
+ payload.update(options)
275
+ # 最多尝试 2 次
276
+ max_attempts = 2
277
+ for attempt in range(max_attempts):
278
+ try:
279
+ with requests.post(
280
+ f"{self.base_url}/chat/completions", json=payload, stream=True,
281
+ headers={"Authorization": f"Bearer {self.api_key}",
282
+ "Content-Type": "application/json"},
283
+ timeout=self.timeout,
284
+ ) as resp:
285
+ resp.raise_for_status()
286
+ for line in resp.iter_lines(decode_unicode=True):
287
+ if not line or not line.startswith("data:"):
288
+ continue
289
+ data_str = line[len("data:"):].strip()
290
+ if data_str == "[DONE]":
291
+ return
292
+ try:
293
+ chunk = json.loads(data_str)
294
+ except json.JSONDecodeError:
295
+ continue
296
+ delta = (chunk.get("choices", [{}])[0]
297
+ .get("delta", {}).get("content", ""))
298
+ if delta:
299
+ yield ChatResponse(
300
+ generations=[Generation(output=Message.assistant(delta))],
301
+ metadata={"provider": "openai", "stream": True},
302
+ )
303
+ return # 成功完成,退出
304
+ except Exception as exc:
305
+ # 仅对瞬态(连接/超时/429/5xx)重试;401/403/400 等永久错误直接抛
306
+ if not _is_transient_http_exc(exc, locals().get("resp", None)):
307
+ raise
308
+ logger.warning("流式 SSE 第 %d 次尝试失败: %s", attempt + 1, exc)
309
+ if attempt < max_attempts - 1:
310
+ _time.sleep(1)
311
+ continue
312
+ logger.error("流式 SSE 重试耗尽,降级: %s", exc)
313
+ yield ChatResponse(
314
+ generations=[Generation(output=Message.assistant(
315
+ "(流式响应中断,请重试)"))],
316
+ metadata={"provider": "openai", "stream": True,
317
+ "error": str(exc)},
318
+ )
319
+ return
320
+
321
+ # ---------- 消息序列化 ----------
322
+
323
+ def _serialize_msg(self, m: Message) -> Dict[str, Any]:
324
+ d = m.to_dict()
325
+ if m.type == MessageType.TOOL:
326
+ d["role"] = "tool"
327
+ if m.metadata.get("tool_call_id"):
328
+ d["tool_call_id"] = m.metadata["tool_call_id"]
329
+ # assistant 消息携带 tool_calls 时重发(OpenAI 协议要求)
330
+ if m.type == MessageType.ASSISTANT and m.metadata.get("tool_calls"):
331
+ d["tool_calls"] = m.metadata["tool_calls"]
332
+ return d
333
+
334
+
335
+ class OpenAIEmbeddingModel(EmbeddingModel):
336
+ """OpenAI 兼容嵌入模型"""
337
+
338
+ def __init__(self, api_key: str = "", base_url: str = "",
339
+ model: str = "text-embedding-3-small", timeout: int = 60,
340
+ max_retries: int = 3, retry_delay_ms: int = 500,
341
+ circuit_breaker=None):
342
+ self.api_key = api_key
343
+ self.base_url = base_url.rstrip("/") if base_url else "https://api.openai.com/v1"
344
+ self.model = model
345
+ self.timeout = timeout
346
+ self.max_retries = max_retries
347
+ self.retry_delay_ms = retry_delay_ms
348
+ self.circuit_breaker = circuit_breaker
349
+ self._embedder = None
350
+ if _has_langchain_openai() and api_key:
351
+ try:
352
+ from langchain_openai import OpenAIEmbeddings
353
+ self._embedder = OpenAIEmbeddings(
354
+ api_key=api_key,
355
+ base_url=self.base_url if base_url else None,
356
+ model=model,
357
+ )
358
+ except Exception as exc: # pragma: no cover
359
+ logger.warning("OpenAIEmbeddings 初始化失败,降级HTTP: %s", exc)
360
+ self._embedder = None
361
+
362
+ def embed(self, texts: List[str]) -> List[List[float]]:
363
+ if self._embedder is not None:
364
+ try:
365
+ return self._embedder.embed_documents(texts)
366
+ except Exception as exc: # pragma: no cover
367
+ logger.warning("LangChain 嵌入失败,降级HTTP: %s", exc)
368
+ return self._embed_via_http(texts)
369
+
370
+ def _embed_via_http(self, texts: List[str]) -> List[List[float]]:
371
+ data = _http_post_json(
372
+ f"{self.base_url}/embeddings",
373
+ json_body={"model": self.model, "input": texts},
374
+ headers={"Authorization": f"Bearer {self.api_key}",
375
+ "Content-Type": "application/json"},
376
+ timeout=self.timeout,
377
+ max_retries=self.max_retries,
378
+ retry_delay_ms=self.retry_delay_ms,
379
+ circuit_breaker=self.circuit_breaker,
380
+ provider="openai",
381
+ )
382
+ return [item["embedding"] for item in data["data"]]
383
+
384
+
385
+ # ==================== OpenAI 兼容多 Provider(DeepSeek/Moonshot/ZhipuAI) ====================
386
+
387
+ class OpenAICompatChatModel(ChatModel):
388
+ """
389
+ OpenAI 兼容多厂商聊天模型 - 对齐"能用 LangChain 就用 LangChain"方向。
390
+
391
+ 用于 DeepSeek / Moonshot(Kimi) / ZhipuAI(GLM) 等提供 OpenAI 兼容
392
+ /chat/completions 接口的厂商:
393
+ - 优先:若安装了对应的专用 langchain-* 包(langchain_deepseek /
394
+ langchain_moonshot / langchain_zhipuai)且配置了 api_key,
395
+ 则经 LangChain 调用(backend=langchain),复用其成熟生态
396
+ - 降级:未安装/初始化失败时走原生 HTTP(backend=http),
397
+ 统一复用 _http_post_json 的重试/熔断/工具闭环,保证开箱即用
398
+
399
+ 参数 langchain_module / langchain_class 指定要优先使用的 LangChain 包,
400
+ 未指定或不可用时自动回退 HTTP。
401
+ """
402
+
403
+ def __init__(self, provider: str, api_key: str = "", base_url: str = "",
404
+ model: str = "", temperature: float = 0.7, timeout: int = 120,
405
+ max_retries: int = 3, retry_delay_ms: int = 500,
406
+ circuit_breaker=None, langchain_module: Optional[str] = None,
407
+ langchain_class: str = "", default_base_url: str = "",
408
+ default_model: str = ""):
409
+ self._provider = provider
410
+ self.api_key = api_key
411
+ self.base_url = (base_url or default_base_url).rstrip("/")
412
+ self.model = model or default_model
413
+ self.temperature = temperature
414
+ self.timeout = timeout
415
+ self.max_retries = max_retries
416
+ self.retry_delay_ms = retry_delay_ms
417
+ self.circuit_breaker = circuit_breaker
418
+ self._llm = None
419
+ if langchain_module and api_key and _has_langchain(langchain_module):
420
+ try:
421
+ mod = __import__(langchain_module, fromlist=[langchain_class])
422
+ cls = getattr(mod, langchain_class)
423
+ kwargs: Dict[str, Any] = {"api_key": api_key, "model": self.model,
424
+ "temperature": temperature}
425
+ if self.base_url:
426
+ kwargs["base_url"] = self.base_url
427
+ self._llm = cls(**kwargs)
428
+ except Exception as exc: # pragma: no cover
429
+ logger.warning("%s LangChain 初始化失败,降级HTTP: %s",
430
+ provider, exc)
431
+ self._llm = None
432
+
433
+ def call(self, messages, tool_registry=None, options=None):
434
+ """同步调用(基类闭环 + 整体指标)"""
435
+ from spring.ai.observability import ai_metrics
436
+ start = time.time()
437
+ try:
438
+ resp = super().call(messages, tool_registry, options)
439
+ usage = (resp.metadata or {}).get("usage") if resp else None
440
+ ai_metrics.record_call(self._provider, self.model, "success",
441
+ time.time() - start, usage)
442
+ return resp
443
+ except Exception:
444
+ ai_metrics.record_call(self._provider, self.model, "failure",
445
+ time.time() - start)
446
+ raise
447
+
448
+ def _raw_call(self, messages, tool_registry=None, options=None):
449
+ if self._llm is not None:
450
+ result = self._llm.invoke([(m.type, m.content) for m in messages])
451
+ content = result.content if hasattr(result, "content") else str(result)
452
+ return ChatResponse(
453
+ generations=[Generation(output=Message.assistant(content))],
454
+ metadata={"provider": self._provider, "backend": "langchain"})
455
+ return self._call_via_http(messages, tool_registry, options)
456
+
457
+ def _call_via_http(self, messages, tool_registry, options):
458
+ payload = {"model": self.model,
459
+ "messages": [self._serialize_msg(m) for m in messages],
460
+ "temperature": self.temperature}
461
+ if options:
462
+ payload.update(options)
463
+ if _is_tool_registry(tool_registry) and tool_registry.names():
464
+ payload["tools"] = tool_registry.schemas()
465
+
466
+ data = _http_post_json(
467
+ f"{self.base_url}/chat/completions",
468
+ json_body=payload,
469
+ headers={"Authorization": f"Bearer {self.api_key}",
470
+ "Content-Type": "application/json"},
471
+ timeout=self.timeout, max_retries=self.max_retries,
472
+ retry_delay_ms=self.retry_delay_ms,
473
+ circuit_breaker=self.circuit_breaker,
474
+ provider=self._provider,
475
+ )
476
+ choice = data.get("choices", [{}])[0]
477
+ msg_obj = choice.get("message", {})
478
+ tool_calls = msg_obj.get("tool_calls")
479
+ content = msg_obj.get("content", "") or ""
480
+ meta = {"provider": self._provider, "backend": "http",
481
+ "usage": data.get("usage", {})}
482
+ if tool_calls:
483
+ meta["tool_calls"] = tool_calls
484
+ return ChatResponse(
485
+ generations=[Generation(output=Message(
486
+ content=content, type=MessageType.ASSISTANT,
487
+ metadata={"tool_calls": tool_calls or []}))],
488
+ metadata=meta)
489
+
490
+ def stream(self, messages, tool_registry=None, options=None):
491
+ if self._llm is not None:
492
+ for chunk in self._llm.stream([(m.type, m.content) for m in messages]):
493
+ content = chunk.content if hasattr(chunk, "content") else str(chunk)
494
+ if content:
495
+ yield ChatResponse(
496
+ generations=[Generation(output=Message.assistant(content))],
497
+ metadata={"provider": self._provider, "stream": True})
498
+ return
499
+ yield from self._stream_via_http(messages, options)
500
+
501
+ def _stream_via_http(self, messages, options):
502
+ import requests
503
+ import time as _time
504
+ payload = {"model": self.model, "temperature": self.temperature,
505
+ "messages": [self._serialize_msg(m) for m in messages],
506
+ "stream": True}
507
+ if options:
508
+ payload.update(options)
509
+ max_attempts = 2
510
+ for attempt in range(max_attempts):
511
+ try:
512
+ with requests.post(
513
+ f"{self.base_url}/chat/completions", json=payload,
514
+ stream=True,
515
+ headers={"Authorization": f"Bearer {self.api_key}",
516
+ "Content-Type": "application/json"},
517
+ timeout=self.timeout,
518
+ ) as resp:
519
+ resp.raise_for_status()
520
+ for line in resp.iter_lines(decode_unicode=True):
521
+ if not line or not line.startswith("data:"):
522
+ continue
523
+ data_str = line[len("data:"):].strip()
524
+ if data_str == "[DONE]":
525
+ return
526
+ try:
527
+ chunk = json.loads(data_str)
528
+ except json.JSONDecodeError:
529
+ continue
530
+ delta = (chunk.get("choices", [{}])[0]
531
+ .get("delta", {}).get("content", ""))
532
+ if delta:
533
+ yield ChatResponse(
534
+ generations=[Generation(output=Message.assistant(delta))],
535
+ metadata={"provider": self._provider, "stream": True})
536
+ return
537
+ except Exception as exc:
538
+ if not _is_transient_http_exc(exc, locals().get("resp", None)):
539
+ raise
540
+ logger.warning("%s 流式第 %d 次尝试失败: %s",
541
+ self._provider, attempt + 1, exc)
542
+ if attempt < max_attempts - 1:
543
+ _time.sleep(1)
544
+ continue
545
+ yield ChatResponse(
546
+ generations=[Generation(output=Message.assistant(
547
+ "(流式响应中断,请重试)"))],
548
+ metadata={"provider": self._provider, "stream": True,
549
+ "error": str(exc)})
550
+ return
551
+
552
+ def _serialize_msg(self, m: Message) -> Dict[str, Any]:
553
+ d = m.to_dict()
554
+ if m.type == MessageType.TOOL:
555
+ d["role"] = "tool"
556
+ if m.metadata.get("tool_call_id"):
557
+ d["tool_call_id"] = m.metadata["tool_call_id"]
558
+ if m.type == MessageType.ASSISTANT and m.metadata.get("tool_calls"):
559
+ d["tool_calls"] = m.metadata["tool_calls"]
560
+ return d
561
+
562
+
563
+ # ==================== Ollama ====================
564
+
565
+ class OllamaChatModel(ChatModel):
566
+ """Ollama 本地聊天模型(Llama3/Qwen/Phi3 等)"""
567
+
568
+ MAX_TOOL_ITERATIONS = 5
569
+
570
+ def __init__(self, base_url: str = "http://localhost:11434",
571
+ model: str = "llama3", temperature: float = 0.7,
572
+ timeout: int = 120, max_retries: int = 3,
573
+ retry_delay_ms: int = 500, circuit_breaker=None):
574
+ self.base_url = base_url.rstrip("/")
575
+ self.model = model
576
+ self.temperature = temperature
577
+ self.timeout = timeout
578
+ self.max_retries = max_retries
579
+ self.retry_delay_ms = retry_delay_ms
580
+ self.circuit_breaker = circuit_breaker
581
+ self._llm = None
582
+ if _has_langchain_community():
583
+ try:
584
+ from langchain_community.chat_models import ChatOllama
585
+ self._llm = ChatOllama(
586
+ base_url=self.base_url, model=model,
587
+ temperature=temperature,
588
+ )
589
+ except Exception as exc: # pragma: no cover
590
+ logger.warning("ChatOllama 初始化失败,降级HTTP: %s", exc)
591
+ self._llm = None
592
+
593
+ def call(self, messages, tool_registry=None, options=None):
594
+ """同步调用(基类闭环 + 整体指标)"""
595
+ from spring.ai.observability import ai_metrics
596
+ start = time.time()
597
+ try:
598
+ resp = super().call(messages, tool_registry, options)
599
+ ai_metrics.record_call("ollama", self.model, "success",
600
+ time.time() - start)
601
+ return resp
602
+ except Exception:
603
+ ai_metrics.record_call("ollama", self.model, "failure",
604
+ time.time() - start)
605
+ raise
606
+
607
+ def _raw_call(self, messages, tool_registry=None, options=None):
608
+ if self._llm is not None:
609
+ lc_messages = [(m.type, m.content) for m in messages]
610
+ result = self._llm.invoke(lc_messages)
611
+ content = result.content if hasattr(result, "content") else str(result)
612
+ return ChatResponse(
613
+ generations=[Generation(output=Message.assistant(content))],
614
+ metadata={"provider": "ollama", "backend": "langchain"})
615
+ return self._call_via_http(messages, options)
616
+
617
+ def _call_via_http(self, messages, options):
618
+ data = _http_post_json(
619
+ f"{self.base_url}/api/chat",
620
+ json_body={"model": self.model, "stream": False,
621
+ "messages": [m.to_dict() for m in messages],
622
+ "options": {"temperature": self.temperature}},
623
+ timeout=self.timeout,
624
+ max_retries=self.max_retries,
625
+ retry_delay_ms=self.retry_delay_ms,
626
+ circuit_breaker=self.circuit_breaker,
627
+ provider="ollama",
628
+ )
629
+ content = data.get("message", {}).get("content", "")
630
+ return ChatResponse(
631
+ generations=[Generation(output=Message.assistant(content))],
632
+ metadata={"provider": "ollama", "backend": "http"})
633
+
634
+ def stream(self, messages, tool_registry=None, options=None):
635
+ import requests
636
+ import time as _time
637
+ max_attempts = 2
638
+ for attempt in range(max_attempts):
639
+ try:
640
+ with requests.post(
641
+ f"{self.base_url}/api/chat", stream=True, timeout=self.timeout,
642
+ json={"model": self.model, "stream": True,
643
+ "messages": [m.to_dict() for m in messages]},
644
+ ) as resp:
645
+ resp.raise_for_status()
646
+ for line in resp.iter_lines(decode_unicode=True):
647
+ if not line:
648
+ continue
649
+ try:
650
+ chunk = json.loads(line)
651
+ except json.JSONDecodeError:
652
+ continue
653
+ delta = chunk.get("message", {}).get("content", "")
654
+ if delta:
655
+ yield ChatResponse(
656
+ generations=[Generation(output=Message.assistant(delta))],
657
+ metadata={"provider": "ollama", "stream": True})
658
+ return
659
+ except Exception as exc:
660
+ # 仅对瞬态(连接/超时/429/5xx)重试;其余错误直接抛
661
+ if not _is_transient_http_exc(exc, locals().get("resp", None)):
662
+ raise
663
+ logger.warning("Ollama 流式第 %d 次尝试失败: %s", attempt + 1, exc)
664
+ if attempt < max_attempts - 1:
665
+ _time.sleep(1)
666
+ continue
667
+ logger.error("Ollama 流式重试耗尽,降级: %s", exc)
668
+ yield ChatResponse(
669
+ generations=[Generation(output=Message.assistant(
670
+ "(流式响应中断,请重试)"))],
671
+ metadata={"provider": "ollama", "stream": True,
672
+ "error": str(exc)},
673
+ )
674
+ return
675
+
676
+
677
+ class OllamaEmbeddingModel(EmbeddingModel):
678
+ """Ollama 嵌入模型"""
679
+
680
+ def __init__(self, base_url: str = "http://localhost:11434",
681
+ model: str = "llama3", timeout: int = 60,
682
+ max_retries: int = 3, retry_delay_ms: int = 500,
683
+ circuit_breaker=None):
684
+ self.base_url = base_url.rstrip("/")
685
+ self.model = model
686
+ self.timeout = timeout
687
+ self.max_retries = max_retries
688
+ self.retry_delay_ms = retry_delay_ms
689
+ self.circuit_breaker = circuit_breaker
690
+
691
+ def embed(self, texts: List[str]) -> List[List[float]]:
692
+ def _embed_one(text):
693
+ data = _http_post_json(
694
+ f"{self.base_url}/api/embeddings",
695
+ json_body={"model": self.model, "prompt": text},
696
+ timeout=self.timeout,
697
+ max_retries=self.max_retries,
698
+ retry_delay_ms=self.retry_delay_ms,
699
+ circuit_breaker=self.circuit_breaker,
700
+ provider="ollama",
701
+ )
702
+ return data.get("embedding", [])
703
+
704
+ return [_embed_one(t) for t in texts]
705
+
706
+
707
+ # ==================== 测试用 Fake ====================
708
+
709
+ class FakeChatModel(ChatModel):
710
+ """
711
+ 确定性聊天模型 - 测试用,不依赖网络。
712
+ 回复 echo 输入的最后一句话。
713
+ 支持模拟函数调用:当 tool_registry 非空且用户消息含 "调用工具" 时,
714
+ 第一轮在 metadata['tool_calls'] 标记工具调用(由基类闭环执行),
715
+ 下一轮返回工具结果摘要,验证闭环。
716
+ """
717
+
718
+ def __init__(self, prefix: str = "AI:", simulate_tool_call: bool = False):
719
+ self.prefix = prefix
720
+ self.simulate_tool_call = simulate_tool_call
721
+ self.call_count = 0
722
+
723
+ def _raw_call(self, messages, tool_registry=None, options=None):
724
+ self.call_count += 1
725
+ last_user = ""
726
+ for msg in reversed(messages):
727
+ if msg.type == "user":
728
+ last_user = msg.content
729
+ break
730
+
731
+ # 模拟函数调用闭环:检测 tool 消息回填后给出最终回复
732
+ has_tool_result = any(m.type == MessageType.TOOL for m in messages)
733
+ if (self.simulate_tool_call and _is_tool_registry(tool_registry)
734
+ and "调用工具" in last_user and not has_tool_result
735
+ and tool_registry.names()):
736
+ # 第一轮:在 metadata 标记 tool_calls,基类 call() 闭环执行
737
+ tool_name = tool_registry.names()[0]
738
+ return ChatResponse(
739
+ generations=[Generation(output=Message(
740
+ content=f"[需要调用工具 {tool_name}]",
741
+ type=MessageType.ASSISTANT,
742
+ metadata={"tool_calls": [{"id": "call_fake",
743
+ "function": {"name": tool_name,
744
+ "arguments": "{}"}}]}))],
745
+ metadata={"provider": "fake", "tool_calls": [
746
+ {"id": "call_fake",
747
+ "function": {"name": tool_name, "arguments": "{}"}}]},
748
+ )
749
+
750
+ # 普通回复或工具回填后回复
751
+ content = f"{self.prefix} {last_user}"
752
+ if has_tool_result:
753
+ tool_msgs = [m for m in messages if m.type == MessageType.TOOL]
754
+ content = f"{self.prefix} 工具返回: {tool_msgs[-1].content}"
755
+ return ChatResponse(
756
+ generations=[Generation(output=Message.assistant(content))],
757
+ metadata={"provider": "fake", "call_count": self.call_count},
758
+ )
759
+
760
+ def stream(self, messages, tool_registry=None, options=None):
761
+ """模拟流式:逐 2 字符 yield(不走工具闭环)"""
762
+ resp = self._raw_call(messages, tool_registry, options)
763
+ content = resp.content()
764
+ for i in range(0, len(content), 2):
765
+ yield ChatResponse(
766
+ generations=[Generation(output=Message.assistant(content[i:i+2]))],
767
+ metadata={"provider": "fake", "stream": True},
768
+ )
769
+
770
+
771
+ class FakeEmbeddingModel(EmbeddingModel):
772
+ """确定性嵌入模型 - 哈希到固定维度向量,测试用"""
773
+
774
+ def __init__(self, dim: int = 8):
775
+ self.dim = dim
776
+ self.call_count = 0
777
+
778
+ def embed(self, texts: List[str]) -> List[List[float]]:
779
+ self.call_count += 1
780
+ results = []
781
+ for text in texts:
782
+ vec = [0.0] * self.dim
783
+ for i, ch in enumerate(text):
784
+ vec[i % self.dim] += (ord(ch) % 10) / 10.0
785
+ norm = sum(v * v for v in vec) ** 0.5
786
+ if norm > 0:
787
+ vec = [v / norm for v in vec]
788
+ results.append(vec)
789
+ return results