infinity_llm_client 0.1.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.
- infinity_llm_client-0.1.0.dist-info/METADATA +110 -0
- infinity_llm_client-0.1.0.dist-info/RECORD +26 -0
- infinity_llm_client-0.1.0.dist-info/WHEEL +5 -0
- infinity_llm_client-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- infinity_llm_client-0.1.0.dist-info/top_level.txt +1 -0
- llm/__init__.py +60 -0
- llm/clients/__init__.py +63 -0
- llm/clients/base.py +78 -0
- llm/clients/config.py +28 -0
- llm/clients/exceptions.py +129 -0
- llm/clients/open_ai/__init__.py +27 -0
- llm/clients/open_ai/aggregation.py +63 -0
- llm/clients/open_ai/client.py +261 -0
- llm/clients/open_ai/config.py +34 -0
- llm/clients/open_ai/connection.py +167 -0
- llm/clients/open_ai/open_ai_models.py +121 -0
- llm/clients/open_ai/response_format.py +42 -0
- llm/clients/provider.py +38 -0
- llm/models/__init__.py +58 -0
- llm/models/chunks.py +47 -0
- llm/models/messages.py +131 -0
- llm/models/tools.py +213 -0
- llm/py.typed +0 -0
- llm/tools/__init__.py +6 -0
- llm/tools/introspection.py +378 -0
- llm/tools/registry.py +251 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""OpenAI 兼容 API 的流式客户端实现。
|
|
2
|
+
|
|
3
|
+
核心职责:
|
|
4
|
+
- SSE 流解析与事件分发
|
|
5
|
+
- tool_calls 跨 chunk 增量聚合
|
|
6
|
+
- 流终止时的收尾处理
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import types
|
|
13
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from ...models import (
|
|
18
|
+
DoneChunk,
|
|
19
|
+
FinishChunk,
|
|
20
|
+
Messages,
|
|
21
|
+
StreamChunk,
|
|
22
|
+
TextChunk,
|
|
23
|
+
ToolCall,
|
|
24
|
+
ToolCallCompleteChunk,
|
|
25
|
+
ToolDefinitions,
|
|
26
|
+
UsageChunk,
|
|
27
|
+
UsageStats,
|
|
28
|
+
)
|
|
29
|
+
from ..base import LLMClient
|
|
30
|
+
from ..exceptions import (
|
|
31
|
+
LLMContentFilterError,
|
|
32
|
+
LLMNetworkError,
|
|
33
|
+
LLMStreamError,
|
|
34
|
+
)
|
|
35
|
+
from .aggregation import aggregate_tool_call_deltas
|
|
36
|
+
from .config import OpenAIConfig
|
|
37
|
+
from .connection import ConnectionManager
|
|
38
|
+
from .open_ai_models import (
|
|
39
|
+
Choice,
|
|
40
|
+
StreamEvent,
|
|
41
|
+
ToolCallDelta,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _ToolCallAccumulator:
|
|
48
|
+
"""跨 chunk 累积 tool_calls 增量并在完成时产出聚合结果。"""
|
|
49
|
+
|
|
50
|
+
__slots__ = ('_deltas',)
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._deltas: List[ToolCallDelta] = []
|
|
54
|
+
|
|
55
|
+
def extend(self, deltas: List[ToolCallDelta]) -> None:
|
|
56
|
+
"""追加一批 tool_calls 增量片段。"""
|
|
57
|
+
self._deltas.extend(deltas)
|
|
58
|
+
|
|
59
|
+
def flush(self) -> List[ToolCall]:
|
|
60
|
+
"""一次性聚合并清空已累积的 tool_calls,直接产出 ToolCall 列表。"""
|
|
61
|
+
if not self._deltas:
|
|
62
|
+
return []
|
|
63
|
+
result = aggregate_tool_call_deltas(self._deltas)
|
|
64
|
+
self._deltas.clear()
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def flush_as_chunk(self) -> Optional[ToolCallCompleteChunk]:
|
|
68
|
+
"""将聚合结果包装为 ToolCallCompleteChunk,无待刷新时返回 None。"""
|
|
69
|
+
aggregated = self.flush()
|
|
70
|
+
if not aggregated:
|
|
71
|
+
return None
|
|
72
|
+
return ToolCallCompleteChunk(
|
|
73
|
+
tool_calls=aggregated,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OpenAIClient(LLMClient):
|
|
78
|
+
"""OpenAI 兼容 API 的轻量异步客户端"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: OpenAIConfig) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
self._conn = ConnectionManager(
|
|
83
|
+
api_key=config.api_key,
|
|
84
|
+
base_url=config.base_url,
|
|
85
|
+
config=config.connection,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def model(self) -> str:
|
|
90
|
+
"""当前使用的模型名称。"""
|
|
91
|
+
return self._config.model
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self) -> 'OpenAIClient':
|
|
94
|
+
await self._conn.ensure_session()
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(
|
|
98
|
+
self,
|
|
99
|
+
exc_type: Optional[type[BaseException]],
|
|
100
|
+
exc_val: Optional[BaseException],
|
|
101
|
+
exc_tb: Optional[types.TracebackType],
|
|
102
|
+
) -> Optional[bool]:
|
|
103
|
+
await self._conn.close()
|
|
104
|
+
|
|
105
|
+
async def stream_chat(
|
|
106
|
+
self,
|
|
107
|
+
messages: Messages,
|
|
108
|
+
tools: Optional[ToolDefinitions] = None,
|
|
109
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
110
|
+
"""流式对话,自动聚合 tool_calls 分片并产出完整 AggregatedToolCall。
|
|
111
|
+
|
|
112
|
+
数据流::
|
|
113
|
+
|
|
114
|
+
HTTP SSE 行 → :meth:`_parse_sse_stream` → StreamEvent
|
|
115
|
+
StreamEvent → :meth:`_handle_sse_event` → StreamChunk (yield)
|
|
116
|
+
流结束后 → :meth:`_on_stream_end` → 补刷 + DoneChunk
|
|
117
|
+
|
|
118
|
+
连接级重试(网络错误、可重试 HTTP 状态码)由
|
|
119
|
+
:class:`ConnectionManager` 内部透明处理,调用方无需关心。
|
|
120
|
+
|
|
121
|
+
异常沿三层异步生成器链向上传播,最终在调用方的
|
|
122
|
+
``async for`` 循环中抛出。
|
|
123
|
+
|
|
124
|
+
:param messages: 对话消息列表
|
|
125
|
+
:param tools: 可选的工具定义列表
|
|
126
|
+
"""
|
|
127
|
+
payload = self._build_stream_payload(messages, tools)
|
|
128
|
+
accumulator = _ToolCallAccumulator()
|
|
129
|
+
|
|
130
|
+
async with self._conn.request('chat/completions', payload) as resp:
|
|
131
|
+
async for event in self._parse_sse_stream(resp):
|
|
132
|
+
async for chunk in self._handle_sse_event(event, accumulator):
|
|
133
|
+
yield chunk
|
|
134
|
+
|
|
135
|
+
# 流终止收尾
|
|
136
|
+
async for chunk in self._on_stream_end(accumulator):
|
|
137
|
+
yield chunk
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# SSE 解析
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
async def _parse_sse_stream(
|
|
144
|
+
self,
|
|
145
|
+
resp: aiohttp.ClientResponse,
|
|
146
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
147
|
+
"""
|
|
148
|
+
从 HTTP 响应中逐行读取 SSE 并解析为 StreamEvent。
|
|
149
|
+
|
|
150
|
+
- 自动跳过注释行与非 data 行
|
|
151
|
+
- 遇到 EOF 或 data: [DONE] 时正常结束
|
|
152
|
+
- 网络读取错误抛出 LLMNetworkError
|
|
153
|
+
- JSON 解析错误抛出 LLMStreamError
|
|
154
|
+
"""
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
line_bytes: bytes = await resp.content.readline()
|
|
158
|
+
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
|
|
159
|
+
raise LLMNetworkError(f'Stream read error: {e}', original_error=e) from e
|
|
160
|
+
|
|
161
|
+
# EOF
|
|
162
|
+
if line_bytes == b'':
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
line: str = line_bytes.decode('utf-8').strip()
|
|
166
|
+
|
|
167
|
+
# 注释行
|
|
168
|
+
if line.startswith(':'):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# 非 data 行
|
|
172
|
+
if not line.startswith('data: '):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# [DONE] 终止标记
|
|
176
|
+
if line == 'data: [DONE]':
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# 解析 JSON
|
|
180
|
+
data_str: str = line[5:].strip()
|
|
181
|
+
try:
|
|
182
|
+
yield StreamEvent.model_validate_json(data_str)
|
|
183
|
+
except json.JSONDecodeError as e:
|
|
184
|
+
raise LLMStreamError(
|
|
185
|
+
f'JSON decode error in stream: {e}',
|
|
186
|
+
original_error=e,
|
|
187
|
+
response_body=data_str,
|
|
188
|
+
) from e
|
|
189
|
+
|
|
190
|
+
def _build_stream_payload(
|
|
191
|
+
self,
|
|
192
|
+
messages: Messages,
|
|
193
|
+
tools: Optional[ToolDefinitions],
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""构建流式请求的 JSON payload。"""
|
|
196
|
+
payload: Dict[str, Any] = {
|
|
197
|
+
'model': self._config.model,
|
|
198
|
+
'messages': [m.model_dump(mode='json') for m in messages],
|
|
199
|
+
'stream': True,
|
|
200
|
+
}
|
|
201
|
+
if tools:
|
|
202
|
+
payload['tools'] = [t.model_dump_tool() for t in tools]
|
|
203
|
+
if self._config.include_usage:
|
|
204
|
+
payload['stream_options'] = {'include_usage': True}
|
|
205
|
+
if self._config.response_format:
|
|
206
|
+
payload['response_format'] = self._config.response_format.model_dump(
|
|
207
|
+
mode='json',
|
|
208
|
+
by_alias=True,
|
|
209
|
+
exclude_none=True,
|
|
210
|
+
)
|
|
211
|
+
return payload
|
|
212
|
+
|
|
213
|
+
async def _handle_sse_event(
|
|
214
|
+
self,
|
|
215
|
+
event: StreamEvent,
|
|
216
|
+
accumulator: _ToolCallAccumulator,
|
|
217
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
218
|
+
"""按固定管线处理单个 SSE 事件"""
|
|
219
|
+
if event.choices:
|
|
220
|
+
choice: Choice = event.choices[0]
|
|
221
|
+
|
|
222
|
+
# Step 1: 文本增量
|
|
223
|
+
if choice.delta.content:
|
|
224
|
+
yield TextChunk(text=choice.delta.content)
|
|
225
|
+
|
|
226
|
+
# Step 2: 工具调用累积
|
|
227
|
+
if choice.delta.tool_calls:
|
|
228
|
+
accumulator.extend(choice.delta.tool_calls)
|
|
229
|
+
|
|
230
|
+
# Step 3: 结束理由
|
|
231
|
+
if choice.finish_reason is not None:
|
|
232
|
+
if choice.finish_reason == 'content_filter':
|
|
233
|
+
raise LLMContentFilterError(
|
|
234
|
+
message='Content filtered by safety system',
|
|
235
|
+
status_code=400,
|
|
236
|
+
)
|
|
237
|
+
if choice.finish_reason == 'tool_calls':
|
|
238
|
+
chunk = accumulator.flush_as_chunk()
|
|
239
|
+
if chunk is not None:
|
|
240
|
+
yield chunk
|
|
241
|
+
yield FinishChunk(finish_reason=choice.finish_reason)
|
|
242
|
+
|
|
243
|
+
# Step 4: Token 用量
|
|
244
|
+
if event.usage and self._config.include_usage:
|
|
245
|
+
yield UsageChunk(
|
|
246
|
+
usage=UsageStats(
|
|
247
|
+
prompt_tokens=event.usage.prompt_tokens,
|
|
248
|
+
completion_tokens=event.usage.completion_tokens,
|
|
249
|
+
total_tokens=event.usage.total_tokens,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def _on_stream_end(
|
|
254
|
+
self,
|
|
255
|
+
accumulator: _ToolCallAccumulator,
|
|
256
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
257
|
+
"""流终止时的收尾:补刷未完成的 tool_calls,产出 DONE 标记。"""
|
|
258
|
+
chunk = accumulator.flush_as_chunk()
|
|
259
|
+
if chunk is not None:
|
|
260
|
+
yield chunk
|
|
261
|
+
yield DoneChunk()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OpenAI 兼容 API 配置模型"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from ..base import LLMClient
|
|
8
|
+
from ..config import LLMConfig
|
|
9
|
+
from ..exceptions import LLMConfigError
|
|
10
|
+
from ..provider import register_client
|
|
11
|
+
from .response_format import ResponseFormat
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIConfig(LLMConfig):
|
|
15
|
+
"""OpenAI 兼容 API 配置"""
|
|
16
|
+
|
|
17
|
+
provider: str = 'openai'
|
|
18
|
+
api_key: str = Field(description='API 密钥')
|
|
19
|
+
base_url: str = Field(default='https://api.openai.com/v1', description='API 基础 URL')
|
|
20
|
+
response_format: Optional[ResponseFormat] = Field(
|
|
21
|
+
default=None,
|
|
22
|
+
description='响应格式约束(JSON 模式 / 结构化输出)',
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register_client('openai')
|
|
27
|
+
def create_openai_client(config: LLMConfig) -> LLMClient:
|
|
28
|
+
"""根据 OpenAIConfig 创建 OpenAIClient 实例。"""
|
|
29
|
+
if not isinstance(config, OpenAIConfig):
|
|
30
|
+
raise LLMConfigError(f'openai factory received incompatible config type: {type(config).__name__}')
|
|
31
|
+
|
|
32
|
+
from .client import OpenAIClient
|
|
33
|
+
|
|
34
|
+
return OpenAIClient(config)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""OpenAI 兼容 API 的 HTTP 连接管理"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any, AsyncGenerator, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from ..config import ConnectionConfig
|
|
13
|
+
from ..exceptions import (
|
|
14
|
+
LLMContentFilterError,
|
|
15
|
+
LLMContextLengthError,
|
|
16
|
+
LLMHTTPError,
|
|
17
|
+
LLMNetworkError,
|
|
18
|
+
build_http_error,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConnectionManager:
|
|
25
|
+
"""管理 aiohttp ClientSession 的生命周期、HTTP 请求发送与连接级重试"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str,
|
|
30
|
+
base_url: str,
|
|
31
|
+
config: Optional[ConnectionConfig] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._api_key: str = api_key
|
|
34
|
+
self._base_url: str = base_url.rstrip('/')
|
|
35
|
+
self._config: ConnectionConfig = config or ConnectionConfig()
|
|
36
|
+
|
|
37
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
38
|
+
self._session_lock: asyncio.Lock = asyncio.Lock()
|
|
39
|
+
|
|
40
|
+
async def ensure_session(self) -> aiohttp.ClientSession:
|
|
41
|
+
"""懒初始化"""
|
|
42
|
+
async with self._session_lock:
|
|
43
|
+
if self._session is None or self._session.closed:
|
|
44
|
+
timeout_config = aiohttp.ClientTimeout(
|
|
45
|
+
total=None,
|
|
46
|
+
sock_connect=self._config.connect_timeout,
|
|
47
|
+
sock_read=self._config.timeout,
|
|
48
|
+
)
|
|
49
|
+
self._session = aiohttp.ClientSession(
|
|
50
|
+
headers={
|
|
51
|
+
'Authorization': f'Bearer {self._api_key}',
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
},
|
|
54
|
+
timeout=timeout_config,
|
|
55
|
+
)
|
|
56
|
+
logger.debug('Created new aiohttp ClientSession')
|
|
57
|
+
return self._session
|
|
58
|
+
|
|
59
|
+
async def close(self) -> None:
|
|
60
|
+
"""关闭底层 HTTP 会话。"""
|
|
61
|
+
async with self._session_lock:
|
|
62
|
+
if self._session and not self._session.closed:
|
|
63
|
+
await self._session.close()
|
|
64
|
+
logger.debug('Closed aiohttp ClientSession')
|
|
65
|
+
self._session = None
|
|
66
|
+
|
|
67
|
+
def _is_retryable(self, error: BaseException) -> bool:
|
|
68
|
+
if isinstance(error, LLMNetworkError):
|
|
69
|
+
return True
|
|
70
|
+
if isinstance(error, LLMHTTPError) and error.status_code is not None:
|
|
71
|
+
return error.status_code in self._config.retryable_status
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def _calc_delay(self, attempt: int) -> float:
|
|
75
|
+
"""计算第 attempt 次重试的等待时间(指数退避 + 可选抖动)。"""
|
|
76
|
+
cfg = self._config
|
|
77
|
+
delay = min(cfg.base_delay * (2 ** (attempt - 1)), cfg.max_delay)
|
|
78
|
+
if cfg.jitter:
|
|
79
|
+
delay = delay * (0.75 + random.random() * 0.5) # ±25% 抖动
|
|
80
|
+
return delay
|
|
81
|
+
|
|
82
|
+
async def _do_request(self, endpoint: str, payload: Dict[str, Any]) -> aiohttp.ClientResponse:
|
|
83
|
+
"""执行单次 POST 请求并校验 HTTP 状态码。
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ClientResponse
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
LLMNetworkError: 连接/超时失败
|
|
90
|
+
LLMHTTPError: 非 200 状态码
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
session: aiohttp.ClientSession = await self.ensure_session()
|
|
94
|
+
url: str = f'{self._base_url}/{endpoint.lstrip("/")}'
|
|
95
|
+
resp: aiohttp.ClientResponse = await session.post(url, json=payload)
|
|
96
|
+
except asyncio.TimeoutError as e:
|
|
97
|
+
raise LLMNetworkError(f'Request timeout: {e}', original_error=e) from e
|
|
98
|
+
except aiohttp.ClientError as e:
|
|
99
|
+
raise LLMNetworkError(f'Connection error: {e}', original_error=e) from e
|
|
100
|
+
|
|
101
|
+
if resp.status == 200:
|
|
102
|
+
return resp
|
|
103
|
+
|
|
104
|
+
error_text: str = await resp.text()
|
|
105
|
+
resp.close()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
error_type: str = json.loads(error_text).get('error', {}).get('type', '')
|
|
109
|
+
except (json.JSONDecodeError, AttributeError):
|
|
110
|
+
error_type = ''
|
|
111
|
+
|
|
112
|
+
match error_type:
|
|
113
|
+
case 'content_filter':
|
|
114
|
+
raise LLMContentFilterError(
|
|
115
|
+
message=f'Content filtered: {error_text}',
|
|
116
|
+
status_code=resp.status,
|
|
117
|
+
response_body=error_text,
|
|
118
|
+
)
|
|
119
|
+
case 'context_length_exceeded':
|
|
120
|
+
raise LLMContextLengthError(
|
|
121
|
+
message=f'Context length exceeded: {error_text}',
|
|
122
|
+
status_code=resp.status,
|
|
123
|
+
response_body=error_text,
|
|
124
|
+
)
|
|
125
|
+
case _:
|
|
126
|
+
raise build_http_error(
|
|
127
|
+
status_code=resp.status,
|
|
128
|
+
message=f'API error {resp.status}: {error_text}',
|
|
129
|
+
response_body=error_text,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def _backoff(self, attempt: int, last_error: Optional[Exception]) -> None:
|
|
133
|
+
delay: float = self._calc_delay(attempt)
|
|
134
|
+
logger.warning(
|
|
135
|
+
'Request failed (attempt %d/%d), retrying in %.1fs: %s',
|
|
136
|
+
attempt,
|
|
137
|
+
self._config.max_retries,
|
|
138
|
+
delay,
|
|
139
|
+
last_error,
|
|
140
|
+
)
|
|
141
|
+
if isinstance(last_error, LLMNetworkError):
|
|
142
|
+
await self.close()
|
|
143
|
+
await asyncio.sleep(delay)
|
|
144
|
+
|
|
145
|
+
@asynccontextmanager
|
|
146
|
+
async def request(self, endpoint: str, payload: Dict[str, Any]) -> AsyncGenerator[aiohttp.ClientResponse, None]:
|
|
147
|
+
last_error: Optional[Exception] = None
|
|
148
|
+
total_attempts = self._config.max_retries + 1
|
|
149
|
+
|
|
150
|
+
for attempt in range(1, total_attempts + 1):
|
|
151
|
+
try:
|
|
152
|
+
resp = await self._do_request(endpoint, payload)
|
|
153
|
+
except (LLMNetworkError, LLMHTTPError) as e:
|
|
154
|
+
if not self._is_retryable(e):
|
|
155
|
+
raise
|
|
156
|
+
last_error = e
|
|
157
|
+
else:
|
|
158
|
+
try:
|
|
159
|
+
yield resp
|
|
160
|
+
return
|
|
161
|
+
finally:
|
|
162
|
+
resp.close()
|
|
163
|
+
|
|
164
|
+
await self._backoff(attempt, last_error)
|
|
165
|
+
|
|
166
|
+
assert last_error is not None
|
|
167
|
+
raise last_error
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""OpenAI 兼容 API 的请求/响应模型定义"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChatCompletionTokenLogprob(BaseModel):
|
|
9
|
+
"""单个 token 的对数概率信息"""
|
|
10
|
+
|
|
11
|
+
token: str = Field(description='Token 字符串')
|
|
12
|
+
bytes: Optional[List[int]] = Field(default=None, description='Token 的字节表示')
|
|
13
|
+
logprob: float = Field(description='Token 的对数概率')
|
|
14
|
+
top_logprobs: Optional[List['ChatCompletionTokenLogprob']] = Field(
|
|
15
|
+
default=None, description='最可能的替代 token 列表'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LogprobsContent(BaseModel):
|
|
20
|
+
"""消息内容 token 的对数概率列表"""
|
|
21
|
+
|
|
22
|
+
content: Optional[List[ChatCompletionTokenLogprob]] = Field(
|
|
23
|
+
default=None, description='消息内容 token 的对数概率信息'
|
|
24
|
+
)
|
|
25
|
+
refusal: Optional[List[ChatCompletionTokenLogprob]] = Field(
|
|
26
|
+
default=None, description='消息拒绝 token 的对数概率信息'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolCallFunction(BaseModel):
|
|
31
|
+
"""工具调用中的函数信息(流式增量)"""
|
|
32
|
+
|
|
33
|
+
name: Optional[str] = Field(default=None, description='函数名称')
|
|
34
|
+
arguments: str = Field(default='', description='JSON 参数片段(流式拼接)')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolCallDelta(BaseModel):
|
|
38
|
+
"""单条工具调用的流式增量片段"""
|
|
39
|
+
|
|
40
|
+
index: int = Field(default=0, description='工具调用在列表中的索引位置')
|
|
41
|
+
id: Optional[str] = Field(default=None, description='工具调用唯一标识符')
|
|
42
|
+
type: Literal['function'] = Field(default='function', description='调用类型')
|
|
43
|
+
function: Optional[ToolCallFunction] = Field(default=None, description='函数名称与参数增量')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Delta(BaseModel):
|
|
47
|
+
"""流式返回的 completion 增量"""
|
|
48
|
+
|
|
49
|
+
content: Optional[str] = Field(default=None, description='增量的文本内容')
|
|
50
|
+
role: Optional[Literal['assistant']] = Field(default=None, description='消息角色')
|
|
51
|
+
function_call: Optional[Dict[Any, Any]] = Field(default=None, description='函数调用信息(已弃用)')
|
|
52
|
+
refusal: Optional[str] = Field(default=None, description='拒绝消息内容')
|
|
53
|
+
tool_calls: Optional[List[ToolCallDelta]] = Field(default=None, description='工具调用增量列表')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompletionTokensDetails(BaseModel):
|
|
57
|
+
"""completion 中 token 的细分统计"""
|
|
58
|
+
|
|
59
|
+
accepted_prediction_tokens: Optional[int] = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description='使用预测输出时,预测中出现在 completion 中的 token 数量',
|
|
62
|
+
)
|
|
63
|
+
audio_tokens: Optional[int] = Field(default=None, description='模型生成的音频 token 数量')
|
|
64
|
+
reasoning_tokens: Optional[int] = Field(default=None, description='模型用于推理的 token 数量')
|
|
65
|
+
rejected_prediction_tokens: Optional[int] = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
description='使用预测输出时,预测中未出现在 completion 中的 token 数量',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PromptTokensDetails(BaseModel):
|
|
72
|
+
"""prompt 中 token 的细分统计"""
|
|
73
|
+
|
|
74
|
+
audio_tokens: Optional[int] = Field(default=None, description='prompt 中的音频 token 数量')
|
|
75
|
+
cached_tokens: Optional[int] = Field(default=None, description='prompt 中的缓存 token 数量')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CompletionUsage(BaseModel):
|
|
79
|
+
"""Token 使用统计"""
|
|
80
|
+
|
|
81
|
+
completion_tokens: int = Field(description='生成的 completion 的 token 数量')
|
|
82
|
+
prompt_tokens: int = Field(description='prompt 的 token 数量')
|
|
83
|
+
total_tokens: int = Field(description='总计 token 数量')
|
|
84
|
+
completion_tokens_details: Optional[CompletionTokensDetails] = Field(
|
|
85
|
+
default=None, description='completion token 的细分信息'
|
|
86
|
+
)
|
|
87
|
+
prompt_tokens_details: Optional[PromptTokensDetails] = Field(default=None, description='prompt token 的细分信息')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Choice(BaseModel):
|
|
91
|
+
"""completion 选择项"""
|
|
92
|
+
|
|
93
|
+
delta: Delta = Field(description='流式增量内容')
|
|
94
|
+
finish_reason: Optional[Literal['stop', 'length', 'content_filter', 'tool_calls', 'function_call']] = Field(
|
|
95
|
+
default=None, description='模型停止生成 token 的原因'
|
|
96
|
+
)
|
|
97
|
+
index: int = Field(description='选择项的索引')
|
|
98
|
+
logprobs: Optional[LogprobsContent] = Field(default=None, description='该 choice 的对数概率信息')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StreamEvent(BaseModel):
|
|
102
|
+
"""OpenAI 兼容流式响应的单个数据块"""
|
|
103
|
+
|
|
104
|
+
id: str = Field(description='对话的唯一标识符,每个 chunk 相同')
|
|
105
|
+
choices: List[Choice] = Field(
|
|
106
|
+
description='completion 选择列表,若 n>1 可包含多个元素;若设置了 include_usage,最后 chunk 可能为空'
|
|
107
|
+
)
|
|
108
|
+
created: int = Field(description='创建时间戳(秒)')
|
|
109
|
+
model: str = Field(description='模型名称')
|
|
110
|
+
object: Literal['chat.completion.chunk'] = Field(
|
|
111
|
+
default='chat.completion.chunk',
|
|
112
|
+
description='对象类型,固定为 chat.completion.chunk',
|
|
113
|
+
)
|
|
114
|
+
service_tier: Optional[Literal['auto', 'default', 'flex', 'scale', 'priority']] = Field(
|
|
115
|
+
default=None, description='实际处理请求的服务层级'
|
|
116
|
+
)
|
|
117
|
+
system_fingerprint: Optional[str] = Field(default=None, description='后端配置指纹,可用于确定性调试')
|
|
118
|
+
usage: Optional[CompletionUsage] = Field(
|
|
119
|
+
default=None,
|
|
120
|
+
description='仅在 stream_options.include_usage 为 true 时存在,通常最后 chunk 包含完整统计',
|
|
121
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""LLM 响应格式约束模型"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonSchema(BaseModel):
|
|
9
|
+
"""JSON Schema 模式 — 结构化输出的 schema 定义
|
|
10
|
+
|
|
11
|
+
用法::
|
|
12
|
+
|
|
13
|
+
# 直接传入 Pydantic 模型类,自动推导 JSON Schema
|
|
14
|
+
schema = JsonSchema(name='tick_output', schema=TickOutput)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name: str = Field(description='schema 名称,模型可见')
|
|
18
|
+
schema_: type[BaseModel] = Field(alias='schema', description='Pydantic 模型类,自动推导 JSON Schema')
|
|
19
|
+
strict: bool = Field(default=True, description='是否启用严格模式')
|
|
20
|
+
|
|
21
|
+
@field_serializer('schema_')
|
|
22
|
+
def _serialize_schema(self, model: type[BaseModel], _info: Any) -> dict[str, Any]:
|
|
23
|
+
return model.model_json_schema()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResponseFormat(BaseModel):
|
|
27
|
+
"""响应格式约束
|
|
28
|
+
|
|
29
|
+
用法::
|
|
30
|
+
|
|
31
|
+
# JSON 模式(模型自由输出 JSON)
|
|
32
|
+
ResponseFormat(type='json_object')
|
|
33
|
+
|
|
34
|
+
# 结构化输出(传入 Pydantic 模型,自动推导 schema)
|
|
35
|
+
ResponseFormat(
|
|
36
|
+
type='json_schema',
|
|
37
|
+
json_schema=JsonSchema(name='tick_output', schema=TickOutput),
|
|
38
|
+
)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
type: Literal['json_object', 'json_schema'] = Field(default='json_object')
|
|
42
|
+
json_schema: JsonSchema | None = Field(default=None)
|
llm/clients/provider.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""LLM 客户端工厂注册表"""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Dict
|
|
4
|
+
|
|
5
|
+
from .base import LLMClient
|
|
6
|
+
from .config import LLMConfig
|
|
7
|
+
|
|
8
|
+
_ClientFactory = Callable[[LLMConfig], LLMClient]
|
|
9
|
+
_registry: Dict[str, _ClientFactory] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_client(provider: str) -> Callable[[_ClientFactory], _ClientFactory]:
|
|
13
|
+
"""装饰器:将工厂函数注册到对应的 provider。
|
|
14
|
+
|
|
15
|
+
用法::
|
|
16
|
+
|
|
17
|
+
@register_client("openai")
|
|
18
|
+
def _create_openai(config: OpenAIConfig) -> LLMClient:
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def decorator(factory: _ClientFactory) -> _ClientFactory:
|
|
23
|
+
_registry[provider] = factory
|
|
24
|
+
return factory
|
|
25
|
+
|
|
26
|
+
return decorator
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_client(config: LLMConfig) -> LLMClient:
|
|
30
|
+
"""根据配置创建对应的 LLM 客户端实例。
|
|
31
|
+
|
|
32
|
+
:param config: LLMConfig 子类实例
|
|
33
|
+
:raises ValueError: 未注册的 provider
|
|
34
|
+
"""
|
|
35
|
+
factory = _registry.get(config.provider)
|
|
36
|
+
if factory is None:
|
|
37
|
+
raise ValueError(f'Unknown LLM provider: {config.provider!r}. Available: {list(_registry.keys())}')
|
|
38
|
+
return factory(config)
|