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.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- 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
|