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.
@@ -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)
@@ -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)