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,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: infinity_llm_client
3
+ Version: 0.1.0
4
+ Summary: 强类型、可扩展的 LLM 异步客户端库,支持 OpenAI 兼容 API、流式对话、工具调用与多模型消息格式
5
+ Author: infinity_system: yin_bailiang
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/yinbailiang/infinity_llm
8
+ Keywords: llm,openai,async,asyncio,function-calling,tool-calling,streaming,chat
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Framework :: AsyncIO
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.md
21
+ Requires-Dist: pydantic
22
+ Requires-Dist: aiohttp
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == "test"
25
+ Requires-Dist: pytest-cov; extra == "test"
26
+ Requires-Dist: pytest-asyncio; extra == "test"
27
+ Provides-Extra: dev
28
+ Requires-Dist: interrogate; extra == "dev"
29
+ Requires-Dist: ruff; extra == "dev"
30
+ Requires-Dist: pyright; extra == "dev"
31
+ Requires-Dist: pre-commit; extra == "dev"
32
+ Requires-Dist: infinity_llm_client[test]; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # InfinityLLM — Async LLM Client
36
+
37
+ [![Test](https://github.com/yinbailiang/infinity_llm/actions/workflows/test.yml/badge.svg)](https://github.com/yinbailiang/infinity_llm/actions/workflows/test.yml)
38
+ [![Pyright](https://img.shields.io/badge/pyright-strict-blue)](ENGINEERING.md)
39
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
40
+ [![Supported Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://pypi.org/project/infinity_llm/)
41
+
42
+ **强类型、可扩展的 LLM 异步客户端库 — 支持 OpenAI 兼容 API、流式对话、工具调用。**
43
+
44
+ ## ✨ Features
45
+
46
+ | 类别 | 能力 |
47
+ | - | - |
48
+ | 类型安全 | Pydantic 负载校验 · pyright **strict** |
49
+ | 多 Provider | 工厂注册模式,轻松扩展新 LLM 服务商 |
50
+ | OpenAI 兼容 | 完整实现 OpenAI Chat Completions 流式 API |
51
+ | 工具调用 | ToolRegistry 自动从 Python 函数推断 Tool Schema |
52
+ | 流式对话 | `stream_chat()` 异步生成器,支持 Text / ToolCall / Usage chunk |
53
+ | 多模态消息 | 文本 + 图片 URL 混合内容 |
54
+ | 连接管理 | 指数退避重试 + 抖动 + 连接池复用 |
55
+
56
+ ## 📦 Installation
57
+
58
+ ```bash
59
+ pip install infinity_llm
60
+ ```
61
+
62
+ ## 🚀 Quick Start
63
+
64
+ ```python
65
+ from llm import (
66
+ Message, Messages,
67
+ OpenAIConfig, create_client,
68
+ ToolRegistry,
69
+ )
70
+
71
+ # 创建客户端
72
+ config = OpenAIConfig(
73
+ model="gpt-4o-mini",
74
+ api_key="sk-...",
75
+ )
76
+ client = create_client(config)
77
+
78
+ # 流式对话
79
+ messages = Messages([
80
+ Message.system("你是一个有用的助手"),
81
+ Message.user("你好"),
82
+ ])
83
+
84
+ async with client:
85
+ async for chunk in client.stream_chat(messages):
86
+ print(chunk)
87
+ ```
88
+
89
+ ## 🧱 Architecture
90
+
91
+ ```
92
+ llm/
93
+ ├── clients/ # 客户端抽象 + provider 实现
94
+ │ ├── base.py # LLMClient 抽象基类
95
+ │ ├── config.py # ConnectionConfig / LLMConfig
96
+ │ ├── exceptions.py # 异常层级
97
+ │ ├── provider.py # 工厂注册表
98
+ │ └── open_ai/ # OpenAI 兼容实现
99
+ ├── models/ # 消息 / Chunk / 工具定义
100
+ │ ├── messages.py # Message, Messages, ToolCall
101
+ │ ├── chunks.py # StreamChunk 子类
102
+ │ └── tools.py # ToolDefinition, ParameterProperty
103
+ └── tools/ # 工具注册与类型推断
104
+ ├── registry.py # ToolRegistry
105
+ └── introspection.py # 函数 → ToolDefinition 自动推断
106
+ ```
107
+
108
+ ## 📄 License
109
+
110
+ MIT
@@ -0,0 +1,26 @@
1
+ infinity_llm_client-0.1.0.dist-info/licenses/LICENSE.md,sha256=MIiitE-EgxYub_R0ZQO4inTK5vTV8DqV-2NNuT29axE,1074
2
+ llm/__init__.py,sha256=hEi-pnBtjNyLWiiZ_9QtMBTHOGxjcgBmUb9mqMK2GXo,1065
3
+ llm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ llm/clients/__init__.py,sha256=aJ0P2ArkKhBM_rl0r-drmIaeRSxoyYFIRh0tzwKWHdI,1356
5
+ llm/clients/base.py,sha256=xqNhzoRVKPWhwEWeHgBgRFjFirKR1GyYF7ah0n-nUI4,2125
6
+ llm/clients/config.py,sha256=NjodJ7s15tz1MVEOjg3yCEb2mMpZD23Pr3Aj6GmWJpA,1281
7
+ llm/clients/exceptions.py,sha256=BwCrWqlJDP1NllLk4O5gZQZVTTOT2QEGp7Vpioe5T98,2852
8
+ llm/clients/provider.py,sha256=Amr_KTlNVoJyENAv7NYxO6HH1JWGBIsis8w43dyI1F0,1066
9
+ llm/clients/open_ai/__init__.py,sha256=9guvJ2sMe39_XD_gqJg0kIbqlEKzC-eY3-PUl9E-2Qw,622
10
+ llm/clients/open_ai/aggregation.py,sha256=1Nm7TDL4esbtrY6dYm5_8MQH9wlX9pygMnO9Q42x9Wk,2096
11
+ llm/clients/open_ai/client.py,sha256=qPmm1qzNd37Tqt2AWyU9jZlmK7wGMXrgGxK3BcrvWN0,8422
12
+ llm/clients/open_ai/config.py,sha256=nRczIlCsjKn5VYlmkCC-gY7VCrkTPl5kC3TsxnZnc_Q,1050
13
+ llm/clients/open_ai/connection.py,sha256=21jCIDmapwQlhqcmEbjjku_B7NWaVrrDWwLuc-TF-lA,5944
14
+ llm/clients/open_ai/open_ai_models.py,sha256=-P9nR0t6uAvtbewruPdSX4RBP5km-WLc2fhBvbcXohU,5352
15
+ llm/clients/open_ai/response_format.py,sha256=kaGkcn4BRiBULBJ8Y3ZtyxXZp6VIBtftcnkc6HI7Nj0,1333
16
+ llm/models/__init__.py,sha256=XOilrS5S02VNIjhf1_YtQ-tpNmn5FG4aJXdiE-SbhT0,1031
17
+ llm/models/chunks.py,sha256=3srBgqfaqKZ4MUAT8zccBDNtC8f2Zc8nNWxkTaC3SRY,1085
18
+ llm/models/messages.py,sha256=masAosZcpcTk2wXobaQjpCq0qmq-d7VAYOKjbh-kkrE,5059
19
+ llm/models/tools.py,sha256=Yh38k0aImLgbuYBbxKr5sF_CchpjfI3Bm2bC-nfJDhk,8689
20
+ llm/tools/__init__.py,sha256=Ogp3oJ6_rI5jTqjXKL2CxdTxGBDfkbdteKmbQzn6fqg,162
21
+ llm/tools/introspection.py,sha256=XkVmWA1tXB7PtXtqwdCLzgwpQ17FhyCZgWGkLxkiQvc,14098
22
+ llm/tools/registry.py,sha256=K8dcU2EP53WYY9v22e64XhsTvvV-3RnZc8evakOb2ok,9267
23
+ infinity_llm_client-0.1.0.dist-info/METADATA,sha256=MlaLt91TBvlSomxVKLZ1aZBGQCSZNa2ZwYmw1OarnH4,3839
24
+ infinity_llm_client-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
25
+ infinity_llm_client-0.1.0.dist-info/top_level.txt,sha256=TwpEQXP3b1MS9Y2XuGgRuo9-Kny507xt2HFZgJ5TSIY,4
26
+ infinity_llm_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2025 infinity_system
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
llm/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """LLM 模块公共 API"""
2
+
3
+ __version__ = '0.1.0'
4
+
5
+ from .models.chunks import (
6
+ DoneChunk,
7
+ FinishChunk,
8
+ StreamChunk,
9
+ TextChunk,
10
+ ToolCallCompleteChunk,
11
+ UsageChunk,
12
+ UsageStats,
13
+ )
14
+ from .models.messages import (
15
+ ContentType,
16
+ FunctionCall,
17
+ ImageUrl,
18
+ Message,
19
+ MessageContent,
20
+ MessageRole,
21
+ Messages,
22
+ MultiModalContent,
23
+ ToolCall,
24
+ )
25
+ from .models.tools import (
26
+ ParameterProperty,
27
+ ToolDefinition,
28
+ ToolDefinitions,
29
+ ToolFunction,
30
+ ToolParameters,
31
+ )
32
+
33
+ __all__ = [
34
+ '__version__',
35
+ # -- 消息 --
36
+ 'Message',
37
+ 'MessageRole',
38
+ 'Messages',
39
+ 'MessageContent',
40
+ 'ContentType',
41
+ 'MultiModalContent',
42
+ 'ImageUrl',
43
+ # -- 工具调用 --
44
+ 'ToolCall',
45
+ 'FunctionCall',
46
+ # -- 工具定义 --
47
+ 'ToolDefinition',
48
+ 'ToolDefinitions',
49
+ 'ToolFunction',
50
+ 'ToolParameters',
51
+ 'ParameterProperty',
52
+ # -- 流式 Chunk --
53
+ 'StreamChunk',
54
+ 'TextChunk',
55
+ 'ToolCallCompleteChunk',
56
+ 'UsageChunk',
57
+ 'FinishChunk',
58
+ 'DoneChunk',
59
+ 'UsageStats',
60
+ ]
@@ -0,0 +1,63 @@
1
+ from ..models import (
2
+ DoneChunk,
3
+ FinishChunk,
4
+ StreamChunk,
5
+ TextChunk,
6
+ ToolCallCompleteChunk,
7
+ UsageChunk,
8
+ UsageStats,
9
+ )
10
+ from .base import LLMClient
11
+ from .config import ConnectionConfig, LLMConfig
12
+ from .exceptions import (
13
+ LLMAuthError,
14
+ LLMConfigError,
15
+ LLMContentFilterError,
16
+ LLMContextLengthError,
17
+ LLMError,
18
+ LLMHTTPError,
19
+ LLMInsufficientBalanceError,
20
+ LLMNetworkError,
21
+ LLMRateLimitError,
22
+ LLMRequestError,
23
+ LLMServerError,
24
+ LLMStreamError,
25
+ build_http_error,
26
+ )
27
+ from .open_ai import OpenAIConfig
28
+ from .provider import create_client, register_client
29
+
30
+ __all__: list[str] = [
31
+ # -- 客户端 --
32
+ 'LLMClient',
33
+ # -- 配置 --
34
+ 'ConnectionConfig',
35
+ 'LLMConfig',
36
+ 'create_client',
37
+ 'register_client',
38
+ # -- provider 配置 --
39
+ 'OpenAIConfig',
40
+ # -- 流式 Chunk --
41
+ 'StreamChunk',
42
+ 'TextChunk',
43
+ 'ToolCallCompleteChunk',
44
+ 'UsageChunk',
45
+ 'FinishChunk',
46
+ 'DoneChunk',
47
+ 'UsageStats',
48
+ # -- 异常 --
49
+ 'LLMError',
50
+ 'LLMConfigError',
51
+ 'LLMRequestError',
52
+ 'LLMNetworkError',
53
+ 'LLMHTTPError',
54
+ 'LLMAuthError',
55
+ 'LLMInsufficientBalanceError',
56
+ 'LLMRateLimitError',
57
+ 'LLMServerError',
58
+ 'LLMContentFilterError',
59
+ 'LLMContextLengthError',
60
+ 'LLMStreamError',
61
+ # -- 工具函数 --
62
+ 'build_http_error',
63
+ ]
llm/clients/base.py ADDED
@@ -0,0 +1,78 @@
1
+ """LLM 客户端抽象基类"""
2
+
3
+ import types
4
+ from abc import ABC, abstractmethod
5
+ from typing import AsyncGenerator, Optional
6
+
7
+ from ..models import (
8
+ Message,
9
+ Messages,
10
+ StreamChunk,
11
+ TextChunk,
12
+ ToolCall,
13
+ ToolCallCompleteChunk,
14
+ ToolDefinitions,
15
+ )
16
+
17
+
18
+ class LLMClient(ABC):
19
+ """LLM 客户端抽象基类"""
20
+
21
+ @abstractmethod
22
+ async def __aenter__(self) -> 'LLMClient':
23
+ """支持 async with 资源管理"""
24
+ ...
25
+
26
+ @abstractmethod
27
+ async def __aexit__(
28
+ self,
29
+ exc_type: Optional[type[BaseException]],
30
+ exc_val: Optional[BaseException],
31
+ exc_tb: Optional[types.TracebackType],
32
+ ) -> Optional[bool]: ...
33
+
34
+ # ---------- 核心对话接口 ----------
35
+
36
+ @abstractmethod
37
+ def stream_chat(
38
+ self,
39
+ messages: Messages,
40
+ tools: Optional[ToolDefinitions] = None,
41
+ ) -> AsyncGenerator[StreamChunk, None]:
42
+ """
43
+ 流式对话接口
44
+
45
+ :param messages: 消息列表
46
+ :param tools: 工具列表
47
+ :yield: StreamChunk 子类实例,调用方通过 isinstance 分支处理
48
+ :raise: LLMError
49
+ """
50
+ ...
51
+
52
+ # ---------- 工具方法 ----------
53
+
54
+ @staticmethod
55
+ def chunks_to_message(chunks: list[StreamChunk]) -> Message:
56
+ """
57
+ 将流式 chunks 聚合为一条完整的 assistant Message
58
+
59
+ 遍历所有 chunk,拼接文本内容并收集工具调用,
60
+ 最终产出一条可直接追加到对话历史的 assistant 消息。
61
+
62
+ :param chunks: 完整的流式 chunk 列表
63
+ :return: role=assistant 的 Message
64
+ """
65
+ text_parts: list[str] = []
66
+ tool_calls: list[ToolCall] = []
67
+
68
+ for chunk in chunks:
69
+ if isinstance(chunk, TextChunk):
70
+ text_parts.append(chunk.text)
71
+ elif isinstance(chunk, ToolCallCompleteChunk):
72
+ tool_calls.extend(chunk.tool_calls)
73
+
74
+ content = ''.join(text_parts) if text_parts else None
75
+ return Message.assistant(
76
+ content=content,
77
+ tool_calls=tool_calls if tool_calls else None,
78
+ )
llm/clients/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """LLM 客户端配置模型与工厂"""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ConnectionConfig(BaseModel):
7
+ """HTTP 连接配置"""
8
+
9
+ timeout: float = Field(default=60.0, gt=0, description='请求读超时(秒)')
10
+ connect_timeout: float = Field(default=10.0, gt=0, description='连接超时(秒)')
11
+
12
+ max_retries: int = Field(default=3, ge=0, description='最大重试次数(0 表示不重试)')
13
+ base_delay: float = Field(default=1.0, gt=0, description='基础退避延迟(秒)')
14
+ max_delay: float = Field(default=30.0, gt=0, description='退避延迟上限(秒)')
15
+ jitter: bool = Field(default=True, description='是否启用 ±25% 随机抖动')
16
+ retryable_status: frozenset[int] = Field(
17
+ default=frozenset({429, 500, 502, 503, 504}),
18
+ description='可触发重试的 HTTP 状态码集合',
19
+ )
20
+
21
+
22
+ class LLMConfig(BaseModel):
23
+ """LLM 客户端通用配置基类"""
24
+
25
+ provider: str = Field(description='服务提供商标识(如 openai)')
26
+ model: str = Field(description='模型名称')
27
+ include_usage: bool = Field(default=True, description='是否请求 token 使用统计')
28
+ connection: ConnectionConfig = Field(default_factory=ConnectionConfig, description='HTTP 连接与重试配置')
@@ -0,0 +1,129 @@
1
+ """LLM 通用异常层级"""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class LLMError(Exception):
7
+ """LLM 基础异常"""
8
+
9
+ pass
10
+
11
+
12
+ class LLMConfigError(LLMError):
13
+ """配置/注册错误"""
14
+
15
+ pass
16
+
17
+
18
+ class LLMRequestError(LLMError):
19
+ """LLM 请求失败的基类"""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ original_error: Optional[Exception] = None,
25
+ status_code: Optional[int] = None,
26
+ response_body: Optional[str] = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.original_error = original_error
30
+ self.status_code = status_code
31
+ self.response_body = response_body
32
+
33
+
34
+ class LLMNetworkError(LLMRequestError):
35
+ """网络层错误:连接失败、超时、DNS 解析等"""
36
+
37
+ pass
38
+
39
+
40
+ class LLMHTTPError(LLMRequestError):
41
+ """HTTP 响应状态码非 200 的错误(基类)"""
42
+
43
+ pass
44
+
45
+
46
+ class LLMAuthError(LLMHTTPError):
47
+ """认证失败:401 或 403"""
48
+
49
+ pass
50
+
51
+
52
+ class LLMInsufficientBalanceError(LLMHTTPError):
53
+ """余额不足:402"""
54
+
55
+ pass
56
+
57
+
58
+ class LLMRateLimitError(LLMHTTPError):
59
+ """速率限制:429"""
60
+
61
+ pass
62
+
63
+
64
+ class LLMServerError(LLMHTTPError):
65
+ """服务端错误:5xx"""
66
+
67
+ pass
68
+
69
+
70
+ class LLMContentFilterError(LLMHTTPError):
71
+ """内容安全过滤"""
72
+
73
+ pass
74
+
75
+
76
+ class LLMContextLengthError(LLMHTTPError):
77
+ """上下文长度超出模型限制"""
78
+
79
+ pass
80
+
81
+
82
+ class LLMStreamError(LLMRequestError):
83
+ """流式响应解析错误"""
84
+
85
+ pass
86
+
87
+
88
+ def build_http_error(
89
+ status_code: int,
90
+ message: Optional[str] = None,
91
+ original_error: Optional[Exception] = None,
92
+ response_body: Optional[str] = None,
93
+ ) -> LLMHTTPError:
94
+ """根据 HTTP 状态码构建对应的异常实例。"""
95
+ if message is None:
96
+ message = f'HTTP {status_code}'
97
+
98
+ if status_code in (401, 403):
99
+ return LLMAuthError(
100
+ message=message,
101
+ status_code=status_code,
102
+ original_error=original_error,
103
+ response_body=response_body,
104
+ )
105
+ elif status_code == 402:
106
+ return LLMInsufficientBalanceError(
107
+ message=message, status_code=status_code, original_error=original_error, response_body=response_body
108
+ )
109
+ elif status_code == 429:
110
+ return LLMRateLimitError(
111
+ message=message,
112
+ status_code=status_code,
113
+ original_error=original_error,
114
+ response_body=response_body,
115
+ )
116
+ elif 500 <= status_code < 600:
117
+ return LLMServerError(
118
+ message=message,
119
+ status_code=status_code,
120
+ original_error=original_error,
121
+ response_body=response_body,
122
+ )
123
+ else:
124
+ return LLMHTTPError(
125
+ message=message,
126
+ status_code=status_code,
127
+ original_error=original_error,
128
+ response_body=response_body,
129
+ )
@@ -0,0 +1,27 @@
1
+ from .aggregation import aggregate_tool_call_deltas
2
+ from .client import OpenAIClient
3
+ from .config import OpenAIConfig, create_openai_client
4
+ from .connection import ConnectionManager
5
+ from .open_ai_models import (
6
+ Choice,
7
+ CompletionUsage,
8
+ Delta,
9
+ StreamEvent,
10
+ ToolCallDelta,
11
+ )
12
+ from .response_format import JsonSchema, ResponseFormat
13
+
14
+ __all__ = [
15
+ 'OpenAIClient',
16
+ 'OpenAIConfig',
17
+ 'create_openai_client',
18
+ 'ConnectionManager',
19
+ 'aggregate_tool_call_deltas',
20
+ 'ResponseFormat',
21
+ 'JsonSchema',
22
+ 'Choice',
23
+ 'CompletionUsage',
24
+ 'Delta',
25
+ 'StreamEvent',
26
+ 'ToolCallDelta',
27
+ ]
@@ -0,0 +1,63 @@
1
+ """OpenAI 流式 tool_calls 增量片段的聚合工具"""
2
+
3
+ import json
4
+ from typing import Dict, List, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ...models import FunctionCall, ToolCall
9
+ from .open_ai_models import ToolCallDelta
10
+
11
+
12
+ class _PendingToolCall(BaseModel):
13
+ """工具调用聚合过程中的暂存状态"""
14
+
15
+ id: Optional[str] = Field(default=None, description='工具调用唯一标识符')
16
+ function_name: Optional[str] = Field(default=None, description='函数名称')
17
+ function_arguments: str = Field(default='', description='已拼接的 JSON 参数字符串')
18
+
19
+
20
+ def aggregate_tool_call_deltas(
21
+ tool_call_deltas: List[ToolCallDelta],
22
+ ) -> List[ToolCall]:
23
+ """
24
+ 将流式 tool_calls 增量片段聚合为完整的 ToolCall 列表。
25
+
26
+ OpenAI 流式协议中,每个 tool_call delta 包含 index 字段,
27
+ 同一 index 的多个 delta 需要拼接 arguments 字符串后解析为 dict。
28
+ """
29
+ pending: Dict[int, _PendingToolCall] = {}
30
+ for tc in tool_call_deltas:
31
+ idx: int = tc.index
32
+ if idx not in pending:
33
+ pending[idx] = _PendingToolCall()
34
+ entry = pending[idx]
35
+ if tc.id:
36
+ entry.id = tc.id
37
+ func = tc.function
38
+ if func is not None:
39
+ if func.name:
40
+ entry.function_name = func.name
41
+ entry.function_arguments += func.arguments
42
+
43
+ result: List[ToolCall] = []
44
+ for idx in sorted(pending):
45
+ entry = pending[idx]
46
+ tool_id: Optional[str] = entry.id
47
+ tool_name: Optional[str] = entry.function_name
48
+ if tool_id is None or tool_name is None:
49
+ continue
50
+ try:
51
+ arguments: Dict[str, object] = json.loads(entry.function_arguments) if entry.function_arguments else {}
52
+ except json.JSONDecodeError:
53
+ arguments = {}
54
+ result.append(
55
+ ToolCall(
56
+ id=tool_id,
57
+ function=FunctionCall(
58
+ name=tool_name,
59
+ arguments=arguments,
60
+ ),
61
+ )
62
+ )
63
+ return result