diff --git a/README.md b/README.md index dc32b37..a193ffb 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ CNLLM Python SDK 为中文大模型提供了一个**统一的 OpenAI 兼容接 - **流式响应** - 通过 `repr()` 进行流式生命周期监测,以及通过 `.still/.think/.tools` 属性访问增量字段自动累积 - **批量能力** - 支持批量任务中单个请求的独立配置,并提供实时批量进度统计 (`.status`),可配置的失败策略 (`stop_on_error`) 和内存管理 (`keep`). -**流式生命周期监控以及模型回复、思考内容、工具调用的自动累积演示:** +**演示:流式生命周期视图与增量提取/自动累积:** ![Figure 2][repr] -[repr]: pics/repr.gif +[repr]: pics/repr_demo.gif ### 开发者招募 @@ -53,12 +53,11 @@ CNLLM Python SDK 为中文大模型提供了一个**统一的 OpenAI 兼容接 ## 更新日志 -### v0.9.3 (2026-05-14) +### v0.9.3 (2026-05-29) -- ✨ **新厂商接入** - - 通义千问 Qwen:qwen3.6/qwen3.5 系列 9 个模型 + Embedding 模型 - - 百度千帆 Baidu:ernie-5.1/ernie-4.5/ernie-speed/ernie-lite/ernie-x1 等 13 个模型 + Embeddings 模型 - - 腾讯混元 Hunyuan:hy3-preview/hunyuan-2.0-thinking/hunyuan-2.0-instruct +- ✨ **上下文构建工具** + - 新增 `ContextBox` 类,一行代码自动格式化模型回复、推理过程、工具调用消息,并加入`messages` 上下文列表。 + - 支持`executor`参数,用于自定义工具执行器函数。 - ✨ **LangChain 集成** - `LangChainRunnable(BaseChatModel)` 中新增支持 `bind_tools()` / `with_structured_output()` 方法 - 新增 `LangChainEmbeddings`:适配 `langchain_core.embeddings.Embeddings`,支持 `embed_documents()` / `embed_query()` @@ -221,28 +220,32 @@ resp = client.chat.create( 流式响应提供**两个访问层**,分别面向不同的使用场景: ```python +from cnllm import ToolCollector + resp = client.chat.create( prompt="用一句话介绍自己", - stream=True + stream=True, + thinking=True, + tools=tools, ) # ── 迭代中:chunk.* 返回逐帧增量,适合前端实时渲染/流式过程监控 ── -with resp.repr as view: # 逐 chunk 合并的字典视图 +with resp as view: # 逐 chunk 合并的完整视图 for chunk in resp: - frontend_still.append(chunk.still) # delta.content,逐字增量 - frontend_think.append(chunk.think) # delta.reasoning_content,逐字增量 - view.refresh() # 实时刷新视图 + frontend_content.append(chunk.still) # delta.content,逐字增量 + frontend_reasoning.append(chunk.think) # delta.reasoning_content,逐字增量 + frontend_tools.update(chunk.tools) # delta.tool_calls,逐 index 归并 + view.refresh() # 实时刷新视图 # ── 流结束后:resp.* 返回完整累积结果,适合取最终值 ── print(resp.still) # 完整的模型回复文本 print(resp.think) # 完整的推理过程 -print(resp) # 字典视图完整累积后的结果 +print(resp.tools) # 完整的工具调用 +print(resp) # 完整合并的 OpenAI dict ``` #### 2.1.3 响应访问 -非流式和流式调用的响应对象提供**统一的属性接口**,流式额外提供**逐帧增量属性**: - **非流式 / 流式通用**(`stream=False` 时可直接访问;`stream=True` 时建议流结束后访问): | 访问方式 | 返回内容 | 返回格式 | 返回示例 | @@ -250,16 +253,40 @@ print(resp) # 字典视图完整累积后的结果 | `resp` | OpenAI 标准响应 | `Dict` / `Iterator[Dict]` | 非流式为完整 dict /流式为 chunk 列表 | | `resp.still` | 模型回复文本(`content`) | `str` | `"你好,我是..."` | | `resp.think` | 推理过程(`reasoning_content`) | `str` | `"推理内容..."` | -| `resp.tools` | 工具调用(`tool_calls`) | `Dict[int, Dict]` | `{0: {"id": "...", "function": {...}}}` | +| `resp.tools` | 工具调用(`tool_calls`) | `List[Dict]` | `[]` | | `resp.raw` | 模型原始响应 | `Dict` / `List[Dict]` | 非流式为完整 dict /流式为 chunks 列表 | -**流式专属**(仅 `stream=True` 时在迭代中访问,返回逐帧增量): +**流式专属**(仅 `stream=True` 时在迭代中访问,返回逐 chunk 增量): | 访问方式 | 返回内容 | 返回格式 | 返回示例 | |---------|---------|---------|---------| | `chunk.still` | 当前 chunk 的 `delta.content` 增量 | `str` | `"你"`, `"好"` | | `chunk.think` | 当前 chunk 的 `delta.reasoning_content` 增量 | `str` | `"思考"`, `"过程"` | -| `resp.repr` | 逐 chunk 合并的字典视图 (实时刷新) | `LiveDict` 上下文管理器 | {实时视图} | +| `chunk.tools` | 当前 chunk 的 `delta.tool_calls` 增量 | `List[Dict]` | `[]` | +| `with resp as view` | 逐 chunk 合并的完整视图 (实时刷新) | `LiveDict` 上下文管理器 | `{实时视图}` | + +#### 2.1.4 对话上下文构建 + +`ContextBox` 将包含了完整上下文内容的 `resp.still` / `resp.think` / `resp.tools` 自动格式化为下一轮对话的 `messages` 列表。 +, +```python +from cnllm import ContextBox + +# 构建 assistant 消息(think + still 自动拼接,tool_calls 自动附着) +messages += ContextBox(resp.still, resp.think) + +# 或在工具调用场景下,传入 executor 自动执行并追加 tool 结果 +def execute_weather_tool(tc): + """tc: {"id": "call_xxx", "function": {"name": "get_weather", "arguments": "..."}}""" + args = json.loads(tc["function"]["arguments"]) + return json.dumps(get_weather(args["location"])) + +messages += ContextBox(resp.still, resp.think, resp.tools, + executor=execute_weather_tool) +# → 自动产出: +# {"role": "assistant", "content": "think...\n\nstill...", "tool_calls": resp.tools} +# {"role": "tool", "tool_call_id": "call_xxx", "content": "工具执行结果"} +``` ### 2.2 chat completions 批量调用 @@ -330,9 +357,10 @@ BatchResponse 外层结构,其中 `results[request_id]` 字段下的每条响 ```python resp = client.chat.batch( prompt=["你好", "今天天气怎么样", "你是谁"], + stream=True, ) -with resp.repr as view: # 实时刷新视图 +with resp as view: # 实时刷新的元数据视图 for r in resp: view.refresh() ``` @@ -340,11 +368,6 @@ with resp.repr as view: # 实时刷新视图 **迭代中实时增量**(流式批量/混合流式批量可用): ```python -resp = client.chat.batch( - prompt=["你好", "今天天气怎么样", "你是谁"], - stream=True, -) - # chunk.* 返回逐帧增量,request_id 自动分流 for chunk in resp: rid = chunk["request_id"] @@ -358,10 +381,10 @@ for chunk in resp: print(resp.still) # {"request_0": "你好", "request_1": "...", "request_2": "..."} print(resp.think) # {"request_0": "推理...", "request_1": "..."} print(resp.tools) # {"request_0": [{"function": {"name": "get_weather", ...}}]} -print(resp) # 视图完整累积后的结果 +print(resp) # 元数据视图完整迭代后的结果 ``` -**访问字段**: +**通用访问字段**: | 访问方式 | 返回内容 | 返回格式 | 返回示例 | |---------|---------|---------|---------| @@ -372,15 +395,15 @@ print(resp) # 视图完整累积后的结果 | `resp.still` | 所有请求的回复 | `Dict[str, str]` | `{"request_0": "你好", "request_1": "..."}` | | `resp.think` | 所有请求的推理 | `Dict[str, str]` | `{"request_0": "推理..."}` | | `resp.tools` | 所有请求的工具调用 | `Dict[str, List[Dict]]` | `{"request_0": [{"function": {...}}]}` | -| `resp.repr` | 实时终端视图 | `LiveDict` / `LiveBatchDict` 上下文管理器 | `{"status": {...}, "usage": {...}}` | +| `with resp as view` | 元数据视图(实时刷新) | `LiveBatchDict` 上下文管理器 | `{"status": {...}, "usage": {...}}` | -**流式 / 混合专属**(迭代中可用): +**流式 / 混合流式批量**(在迭代中访问,返回批量任务中流式请求的逐 chunk 增量): | 访问方式 | 返回内容 | 返回格式 | 返回示例 | |---------|---------|---------|---------| | `chunk.still` | 当前 chunk 增量 | `str` | `"你"` | | `chunk.think` | 当前 chunk 推理增量 | `str` | `"思考"` | -| `chunk["request_id"]` | 标识 chunk 所属请求 | `str` | `"request_0"` | +| `chunk.tools` | 当前 chunk 的 `delta.tool_calls` 增量 | `List[Dict]` | `[]` | **to\_dict():** 将响应转换为字典,保留指定字段,未在 keep 声明的字段若保留会产生警告: @@ -429,13 +452,6 @@ BatchEmbeddingResponse 外层结构,其中 `results[request_id]` 字段下每 resp = client.embeddings.batch( input=["你好", "今天天气怎么样", "你是谁"] ) - -# 终端实时观测 -with resp.repr as view: - for r in resp: - view.refresh() - -print(resp) # 视图完整累积后的结果 ``` **访问字段**: @@ -448,7 +464,7 @@ print(resp) # 视图完整累积后的结果 | `resp.errors` | 失败请求信息 | `Dict[str, str]` | `{"request_0":"error"}` | | `resp.results` | 标准响应 | `Dict[str, Dict]` | `{"request_0": {...}}` | | `resp.vectors` | 嵌入向量表示 | `Dict[str, List[float]]` | `{"request_0":[0.1,0.2,...]}` | -| `resp.repr` | 实时终端视图 | `LiveEmbeddingDict` 上下文管理器 | `{"status": {...}, "usage": {...}, "batch_info": {...}}` | +| `with resp as view` | 元数据视图(实时刷新) | `LiveEmbeddingDict` 上下文管理器 | `{"status": {...}, "usage": {...}, "batch_info": {...}}` | **to\_dict():** 将响应转换为字典,保留指定字段,未在 keep 声明的字段若保留会产生警告: diff --git a/README_en.md b/README_en.md index c628abe..959beb3 100644 --- a/README_en.md +++ b/README_en.md @@ -23,11 +23,11 @@ Through CNLLM, developers can seamlessly use Chinese LLMs in the OpenAI ecosyste - **Streaming Response** - Streaming lifecycle monitoring via `repr()`, and automatic accumulation of incremental fields via `.still`/`.think`/`.tools` property access - **Batch Capability** - Independent configuration for single requests in batch tasks, with real-time batch progress statistics (`.status`), and configurable failure policy (`stop_on_error`) and memory management (`keep`). -**Streaming lifecycle monitoring and automatic accumulation demonstration for model responses, reasoning content, and tool calls:** +**Example:Streaming Lifecycle View and Incremental Extraction/Automatic Accumulation** ![Figure 2][repr] -[repr]: docs/pics/repr.gif +[repr]: pics/repr_demo.gif ### Collaboration Opportunities @@ -53,12 +53,11 @@ Project Documentation: ## Changelog -### v0.9.3 (2026-05-14) +### v0.9.3 (2026-05-29) -- ✨ **New Vendors** - - Qwen: qwen3.6/qwen3.5/qwen-plus/qwen-turbo/qwen-max and 9 models total + Embedding models - - Baidu: ernie-5.1/ernie-4.5/ernie-speed/ernie-lite/ernie-x1 and 13 models total + Embeddings models - - Hunyuan: hy3-preview/hunyuan-2.0-thinking/hunyuan-2.0-instruct +- ✨ **Context Building Tool** + - New `ContextBox` class: one line of code to automatically format model responses, reasoning process, and tool call messages, and add them to the `messages` context list. + - Supports `executor` parameter for custom tool executor function. - ✨ **LangChain Integration** - `LangChainRunnable(BaseChatModel)` adds support for `bind_tools()` / `with_structured_output()` methods - New `LangChainEmbeddings`: adapts `langchain_core.embeddings.Embeddings`, supports `embed_documents()` / `embed_query()` @@ -221,28 +220,32 @@ resp = client.chat.create( Streaming responses provide **two access layers** for different usage scenarios: ```python +from cnllm import ToolCollector + resp = client.chat.create( prompt="Introduce yourself in one sentence", - stream=True + stream=True, + thinking=True, + tools=tools, ) # ── During iteration: chunk.* returns per-frame increments, suitable for frontend real-time rendering / streaming process monitoring ── -with resp.repr as view: # Dictionary view merged chunk by chunk +with resp as view: # Complete view merged chunk by chunk for chunk in resp: - frontend_still.append(chunk.still) # delta.content, character-level increment - frontend_think.append(chunk.think) # delta.reasoning_content, character-level increment - view.refresh() # Real-time refresh view + frontend_content.append(chunk.still) # delta.content, character-level increment + frontend_reasoning.append(chunk.think) # delta.reasoning_content, character-level increment + frontend_tools.update(chunk.tools) # delta.tool_calls, per index merge + view.refresh() # Real-time refresh view # ── After stream ends: resp.* returns complete accumulated results, suitable for getting final values ── print(resp.still) # Complete model response text print(resp.think) # Complete reasoning process -print(resp) # Complete dictionary view accumulated result +print(resp.tools) # Complete tool calls +print(resp) # Complete merged OpenAI dict ``` #### 2.1.3 Response Access -Non-streaming and streaming call response objects provide a **unified property interface**, with streaming additionally providing **per-frame incremental properties**: - **Non-streaming / Streaming common** (can be accessed directly when `stream=False`; recommended to access after stream ends when `stream=True`): | Access Method | Return Content | Return Format | Example | @@ -250,16 +253,39 @@ Non-streaming and streaming call response objects provide a **unified property i | `resp` | OpenAI standard response | `Dict` / `Iterator[Dict]` | Non-streaming returns complete dict / streaming returns chunk list | | `resp.still` | Model response text (`content`) | `str` | `"Hello, I'm..."` | | `resp.think` | Reasoning process (`reasoning_content`) | `str` | `"reasoning content..."` | -| `resp.tools` | Tool calls (`tool_calls`) | `Dict[int, Dict]` | `{0: {"id": "...", "function": {...}}}` | +| `resp.tools` | Tool calls (`tool_calls`) | `List[Dict]` | `[]` | | `resp.raw` | Model native response | `Dict` / `List[Dict]` | Non-streaming returns complete dict / streaming returns chunks list | -**Streaming-exclusive** (only accessible during iteration when `stream=True`, returns per-frame increments): +**Streaming-exclusive** (only accessible during iteration when `stream=True`, returns per-chunk increments): | Access Method | Return Content | Return Format | Example | |-------------|-------------|-------------|---------| | `chunk.still` | Current chunk's `delta.content` increment | `str` | `"Y"`, `"ou"` | | `chunk.think` | Current chunk's `delta.reasoning_content` increment | `str` | `"Th"`, `"ink"` | -| `resp.repr` | Dictionary view merged chunk by chunk (real-time refresh) | `LiveDict` context manager | {real-time view} | +| `chunk.tools` | Current chunk's `delta.tool_calls` increment | `List[Dict]` | `[]` | +| `with resp as view` | Complete view merged chunk by chunk (real-time refresh) | `LiveDict` context manager | `{real-time view}` | + +#### 2.1.4 Context Building for Multi-turn Conversation + +`ContextBox` automatically formats `resp.still` / `resp.think` / `resp.tools` containing complete context content into the `messages` list for the next round of conversation. + +```python +from cnllm import ContextBox + +# Build assistant message (think + still auto-concatenated, tool_calls auto-attached) +messages += ContextBox(resp.still, resp.think) + +# Or in tool calling scenario, pass executor to auto-execute and append tool result +def execute_weather_tool(tc): + """tc: {"id": "call_xxx", "function": {"name": "get_weather", "arguments": "..."}}""" + args = json.loads(tc["function"]["arguments"]) + return json.dumps(get_weather(args["location"])) + +messages += ContextBox(resp.still, resp.think, resp.tools, + executor=execute_weather_tool) +# → Auto produces: +# {"role": "assistant", "content": "think...\n\nstill...", "tool_calls": resp.tools} +# {"role": "tool", "tool_call_id": "call_xxx", "content": "Tool execution result"} ### 2.2 Chat Completions Batch Call @@ -331,9 +357,10 @@ BatchResponse outer structure, where each response under `results[request_id]` i ```python resp = client.chat.batch( prompt=["Hello", "How's the weather today", "Who are you"], + stream=True, ) -with resp.repr as view: # Real-time refresh view +with resp as view: # Real-time refresh metadata view for r in resp: view.refresh() ``` @@ -341,11 +368,6 @@ with resp.repr as view: # Real-time refresh view **Real-time increment during iteration** (streaming batch / mixed streaming batch available): ```python -resp = client.chat.batch( - prompt=["Hello", "How's the weather today", "Who are you"], - stream=True, -) - # chunk.* returns per-frame increments, request_id auto-routes for chunk in resp: rid = chunk["request_id"] @@ -359,10 +381,10 @@ for chunk in resp: print(resp.still) # {"request_0": "Hello", "request_1": "...", "request_2": "..."} print(resp.think) # {"request_0": "reasoning...", "request_1": "..."} print(resp.tools) # {"request_0": [{"function": {"name": "get_weather", ...}}]} -print(resp) # Complete view accumulated result +print(resp) # Complete metadata view accumulated result ``` -**Access fields:** +**Common access fields:** | Access Method | Return Content | Return Format | Example | |-------------|-------------|-------------|---------| @@ -373,15 +395,15 @@ print(resp) # Complete view accumulated result | `resp.still` | All requests' responses | `Dict[str, str]` | `{"request_0": "Hello", "request_1": "..."}` | | `resp.think` | All requests' reasoning | `Dict[str, str]` | `{"request_0": "reasoning..."}` | | `resp.tools` | All requests' tool calls | `Dict[str, List[Dict]]` | `{"request_0": [{"function": {...}}]}` | -| `resp.repr` | Real-time terminal view | `LiveDict` / `LiveBatchDict` context manager | `{"status": {...}, "usage": {...}}` | +| `with resp as view` | Metadata view (real-time refresh) | `LiveBatchDict` context manager | `{"status": {...}, "usage": {...}}` | -**Streaming / Mixed exclusive** (available during iteration): +**Streaming / Mixed streaming batch** (accessible during iteration, returns per-chunk increments for streaming requests in batch): | Access Method | Return Content | Return Format | Example | |-------------|-------------|-------------|---------| | `chunk.still` | Current chunk increment | `str` | `"Y"` | | `chunk.think` | Current chunk reasoning increment | `str` | `"Th"` | -| `chunk["request_id"]` | Identifies which request the chunk belongs to | `str` | `"request_0"` | +| `chunk.tools` | Current chunk's `delta.tool_calls` increment | `List[Dict]` | `[]` | **to_dict():** Converts response to dictionary, preserving specified fields; fields not declared in keep will generate warnings if retained: @@ -428,13 +450,6 @@ BatchEmbeddingResponse outer structure, where each response under `results[reque resp = client.embeddings.batch( input=["Hello", "How's the weather today", "Who are you"] ) - -# Terminal real-time observation -with resp.repr as view: - for r in resp: - view.refresh() - -print(resp) # Complete view accumulated result ``` **Access fields:** @@ -447,7 +462,7 @@ print(resp) # Complete view accumulated result | `resp.errors` | Failed request info | `Dict[str, str]` | `{"request_0":"error"}` | | `resp.results` | Standard response | `Dict[str, Dict]` | `{"request_0": {...}}` | | `resp.vectors` | Embedding vector representation | `Dict[str, List[float]]` | `{"request_0":[0.1,0.2,...]}` | -| `resp.repr` | Real-time terminal view | `LiveEmbeddingDict` context manager | `{"status": {...}, "usage": {...}, "batch_info": {...}}` | +| `with resp as view` | Metadata view (real-time refresh) | `LiveEmbeddingDict` context manager | `{"status": {...}, "usage": {...}, "batch_info": {...}}` | **to_dict():** Converts response to dictionary, preserving specified fields; fields not declared in keep will generate warnings if retained: diff --git a/cnllm/__init__.py b/cnllm/__init__.py index ac31879..08c5eb1 100644 --- a/cnllm/__init__.py +++ b/cnllm/__init__.py @@ -20,15 +20,19 @@ ErrorCode ) from .core.accumulators.embedding_accumulator import EmbeddingResponse +from .core.accumulators.single_accumulator import ToolCollector +from .utils.context import ContextBox from .core import vendor -__version__ = "0.9.3post2" +__version__ = "0.9.3post3" __all__ = [ "CNLLM", "asyncCNLLM", "EmbeddingResponse", + "ToolCollector", + "ContextBox", "CNLLMError", "AuthenticationError", "RateLimitError", diff --git a/cnllm/core/accumulators/base.py b/cnllm/core/accumulators/base.py index fb55cd2..788dfe0 100644 --- a/cnllm/core/accumulators/base.py +++ b/cnllm/core/accumulators/base.py @@ -24,13 +24,16 @@ def still(self) -> str: return self._adapter._cnllm_extra.get("_still", "") if self._adapter else "" @property - def tools(self) -> Dict[int, Dict[str, Any]]: - val = self._adapter._cnllm_extra.get("_tools", {}) if self._adapter else {} + def tools(self) -> List[Dict[str, Any]]: + """工具调用列表,OpenAI 标准 message.tool_calls 格式。""" + val = self._adapter._cnllm_extra.get("_tools", []) if self._adapter else {} if isinstance(val, dict): - return val + # Dict[int, Dict] (internal merge) → List[Dict], strip index + return [dict(tc) for tc in val.values()] if isinstance(val, list): - return {i: tc for i, tc in enumerate(val)} - return {} + # List[Dict] (non-stream raw) → strip stray index + return [{k: v for k, v in tc.items() if k != "index"} for tc in val] + return [] @property def usage(self) -> Dict[str, Any]: @@ -133,6 +136,12 @@ def repr(self) -> "LiveDict": from .live import LiveDict return LiveDict(self) + def __enter__(self): + return self.repr.__enter__() + + def __exit__(self, *args): + return self.repr.__exit__(*args) + def _incremental_merge(self, chunk: Dict[str, Any]) -> None: """增量合并单个 chunk 到 _formatted_chunks 合并 dict。""" if not self._formatted_chunks and chunk: diff --git a/cnllm/core/accumulators/batch_accumulator.py b/cnllm/core/accumulators/batch_accumulator.py index 093c443..02c8cc9 100644 --- a/cnllm/core/accumulators/batch_accumulator.py +++ b/cnllm/core/accumulators/batch_accumulator.py @@ -51,6 +51,16 @@ def __enter__(self): def __exit__(self, *args): if self._live: self._live.__exit__(*args) + # Drain batch iterator if not fully consumed + if hasattr(self._batch, '_done') and not self._batch._done: + try: + if isinstance(self._batch, (list, dict)): + pass + elif hasattr(self._batch, '__iter__'): + for _ in self._batch: + pass + except Exception: + pass import warnings if self._saved_warn_filters is not None: warnings.filters = self._saved_warn_filters @@ -471,7 +481,11 @@ def still(self) -> IndexableDict: def tools(self) -> IndexableDict: self._maybe_wait() self._check_non_keep_warn("tools") - return IndexableDict(self._tools) + # Dict[str, Dict[int, Dict]] → Dict[str, List[Dict]], strip index + converted = {} + for rid, tc_list in self._tools.items(): + converted[rid] = [{k: v for k, v in tc.items() if k != "index"} for tc in tc_list] + return IndexableDict(converted) @property def raw(self) -> IndexableDict: @@ -484,6 +498,12 @@ def repr(self) -> LiveBatchDict: """批量响应的实时终端视图。""" return LiveBatchDict(self) + def __enter__(self): + return self.repr.__enter__() + + def __exit__(self, *args): + return self.repr.__exit__(*args) + @property def usage(self) -> Dict[str, Any]: self._maybe_wait() @@ -496,6 +516,7 @@ def set_still(self, request_id: str, value: str) -> None: self._still[request_id] = value def set_tools(self, request_id: str, value: Dict[int, Dict[str, Any]]) -> None: + # value is internal Dict[int, Dict]; exposed via .tools as List[Dict] self._tools[request_id] = value def set_raw(self, request_id: str, value: Dict[str, Any]) -> None: @@ -882,15 +903,22 @@ def _accumulate_chunk(self, chunk: Dict, request_id: str) -> None: if "_still" in extra_fields: self._batch_response.update_still(request_id, extra_fields["_still"]) if "_tools" in extra_fields: - existing_tools = self._batch_response._tools.get(request_id, {}) + existing_tools = self._batch_response._tools.get(request_id, []) if isinstance(extra_fields["_tools"], list): for tc in extra_fields["_tools"]: if isinstance(tc, dict): - idx = tc.get("index", len(existing_tools)) - if idx in existing_tools: - existing_tools[idx] = self._merge_dicts(existing_tools[idx], tc) + idx = tc.get("index") + if idx is not None: + found = False + for i, et in enumerate(existing_tools): + if et.get("index") == idx: + existing_tools[i] = self._merge_dicts(et, tc) + found = True + break + if not found: + existing_tools.append(dict(tc)) else: - existing_tools[idx] = tc + existing_tools.append(dict(tc)) self._batch_response.set_tools(request_id, existing_tools) def _finalize(self) -> None: @@ -1079,15 +1107,22 @@ def _accumulate_chunk(self, chunk: Dict, request_id: str) -> None: if "_still" in extra_fields: self._batch_response.update_still(request_id, extra_fields["_still"]) if "_tools" in extra_fields: - existing_tools = self._batch_response._tools.get(request_id, {}) + existing_tools = self._batch_response._tools.get(request_id, []) if isinstance(extra_fields["_tools"], list): for tc in extra_fields["_tools"]: if isinstance(tc, dict): - idx = tc.get("index", len(existing_tools)) - if idx in existing_tools: - existing_tools[idx] = self._merge_dicts(existing_tools[idx], tc) + idx = tc.get("index") + if idx is not None: + found = False + for i, et in enumerate(existing_tools): + if et.get("index") == idx: + existing_tools[i] = self._merge_dicts(et, tc) + found = True + break + if not found: + existing_tools.append(dict(tc)) else: - existing_tools[idx] = tc + existing_tools.append(dict(tc)) self._batch_response.set_tools(request_id, existing_tools) async def _finalize(self) -> None: @@ -1293,7 +1328,7 @@ def __next__(self): try: chunk = next(self._current_stream) chunk["request_id"] = self._current_rid - return StreamChunk(chunk) + return chunk except StopIteration: self._finalize_stream() self._current_stream = None @@ -1318,7 +1353,7 @@ def __next__(self): try: chunk = next(result) chunk["request_id"] = request_id - return StreamChunk(chunk) + return chunk except StopIteration: self._finalize_stream() self._current_stream = None @@ -1461,7 +1496,7 @@ async def __anext__(self): try: chunk = await self._current_stream.__anext__() chunk["request_id"] = self._current_rid - return StreamChunk(chunk) + return chunk except StopAsyncIteration: self._finalize_stream() self._current_stream = None @@ -1486,7 +1521,7 @@ async def __anext__(self): try: chunk = await result.__anext__() chunk["request_id"] = request_id - return StreamChunk(chunk) + return chunk except StopAsyncIteration: self._finalize_stream() self._current_stream = None diff --git a/cnllm/core/accumulators/embedding_accumulator.py b/cnllm/core/accumulators/embedding_accumulator.py index 2762699..015a3d2 100644 --- a/cnllm/core/accumulators/embedding_accumulator.py +++ b/cnllm/core/accumulators/embedding_accumulator.py @@ -245,6 +245,12 @@ def repr(self) -> LiveEmbeddingDict: """Embedding 批量响应的实时终端视图。""" return LiveEmbeddingDict(self) + def __enter__(self): + return self.repr.__enter__() + + def __exit__(self, *args): + return self.repr.__exit__(*args) + def add_result(self, request_id: str, result: Dict[str, Any]): self._results[request_id] = result self._success_count += 1 diff --git a/cnllm/core/accumulators/live.py b/cnllm/core/accumulators/live.py index c755e47..32728b6 100644 --- a/cnllm/core/accumulators/live.py +++ b/cnllm/core/accumulators/live.py @@ -29,6 +29,14 @@ def __enter__(self): def __exit__(self, *args): if self._live: self._live.__exit__(*args) + # Drain HTTP stream if not fully consumed + # prevents httpx connection pool pollution on next test/request + if hasattr(self._acc, '_done') and not self._acc._done: + try: + for _ in self._acc: + pass + except Exception: + pass import warnings if self._saved_warn_filters is not None: warnings.filters = self._saved_warn_filters diff --git a/cnllm/core/accumulators/single_accumulator.py b/cnllm/core/accumulators/single_accumulator.py index 65e9747..2375626 100644 --- a/cnllm/core/accumulators/single_accumulator.py +++ b/cnllm/core/accumulators/single_accumulator.py @@ -27,6 +27,16 @@ def think(self) -> str: """当前 chunk 的 reasoning_content 增量(逐帧)。""" return self._get_delta("reasoning_content", "") + @property + def tools(self) -> List[Dict]: + """当前 chunk 的 tool_calls 增量(逐帧),返回 List[Dict]。""" + choices = self.get("choices") + if not choices: + return [] + delta = choices[0].get("delta", {}) + val = delta.get("tool_calls") + return val if val is not None else [] + def _get_delta(self, key, default=""): choices = self.get("choices") if not choices: @@ -378,4 +388,37 @@ def __init__(self, response: Dict[str, Any], adapter, responder=None): self._responder = responder async def process(self) -> Dict[str, Any]: - return super().process(self._response, self._responder) \ No newline at end of file + return super().process(self._response, self._responder) + + +class ToolCollector: + """累积流式 chunk 中的 tool_calls 增量,按 index 归并为完整结构。""" + + def __init__(self): + self._tools: Dict[int, Dict[str, Any]] = {} + + def update(self, tool_calls: List[Dict]) -> None: + if not tool_calls: + return + for tc in tool_calls: + idx = tc.get("index") + if idx is None: + continue + entry = self._tools.setdefault(idx, {"args": ""}) + if "id" in tc: + entry["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"]: + entry["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + entry["args"] += tc["function"]["arguments"] + + def __getitem__(self, idx: int) -> Dict[str, Any]: + return self._tools[idx] + + @property + def all(self) -> Dict[int, Dict[str, Any]]: + return dict(self._tools) + + def __repr__(self) -> str: + return repr(self._tools) diff --git a/cnllm/utils/__init__.py b/cnllm/utils/__init__.py index e69de29..c2b8431 100644 --- a/cnllm/utils/__init__.py +++ b/cnllm/utils/__init__.py @@ -0,0 +1 @@ +from .context import ContextBox diff --git a/cnllm/utils/context.py b/cnllm/utils/context.py new file mode 100644 index 0000000..23042a0 --- /dev/null +++ b/cnllm/utils/context.py @@ -0,0 +1,49 @@ +""" +对话上下文构建工具 + +提供 ``ContextBox`` 用于将 ``resp.*`` 的完整累积结果格式化为 +OpenAI 标准消息列表,自动处理 assistant + tool 消息。 + +用法:: + + from cnllm import ContextBox + + messages += ContextBox(resp.still, resp.think) + # → [{"role": "assistant", "content": "think...\\n\\nstill..."}] + + messages += ContextBox(resp.still, resp.think, resp.tools, + executor=execute_tool) + # → assistant + tool_calls 自动附着,工具执行结果逐条追加 +""" +from typing import Dict, Any, List + + +class ContextBox(list): + """构建对话上下文消息列表,自动处理 assistant + tool 消息。 + + 参数: + still: ``resp.still``,模型回复文本 + think: ``resp.think``,推理过程(可选,自动拼接) + tools: ``resp.tools``,工具调用列表(可选) + executor: 执行工具的可选参数,接收原始 ``tc`` dict, + 返回执行结果字符串 + """ + + def __init__(self, still: str = "", think: str = None, + tools: List[Dict] = None, executor=None): + content = think + "\n\n" + still if think else still + assistant_msg: Dict[str, Any] = {"role": "assistant", + "content": content} + if tools: + assistant_msg["tool_calls"] = tools + msgs: List[Dict[str, Any]] = [assistant_msg] + if tools and executor: + for tc in tools: + msgs.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": executor(tc), + }) + if not msgs[0].get("content") and not msgs[0].get("tool_calls"): + raise ValueError("ContextBox requires at least one of still, think, or tools") + super().__init__(msgs) diff --git a/pics/repr.gif b/pics/repr.gif index 07201b4..19c4de8 100644 Binary files a/pics/repr.gif and b/pics/repr.gif differ diff --git a/pyproject.toml b/pyproject.toml index 4c428f8..a19c317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cnllm" -version = "0.9.3post2" +version = "0.9.3post3" description = "Unified Python library for Chinese LLMs, with flexible batch capacity, feedback on vendor-native parameter validation, and structured overview and automated accumulation for streaming." readme = "README_en.md" license = "Apache-2.0" diff --git a/tests/key_needed/test_batch_stream_chunk_real.py b/tests/key_needed/test_batch_stream_chunk_real.py index 43a614c..60e2ae9 100644 --- a/tests/key_needed/test_batch_stream_chunk_real.py +++ b/tests/key_needed/test_batch_stream_chunk_real.py @@ -147,6 +147,43 @@ def test_batch_stream_chunk_think(self): for chunk in resp: self.assertIsInstance(chunk.think, str) + def test_batch_stream_chunk_tools(self): + # batch 流式中 chunk.tools 逐帧增量 + 按 request_id 分流 + prompts = ["北京的天气?用工具", "上海的天气?用工具"] + resp = self.client.chat.batch( + prompt=prompts, + stream=True, + tools=[WEATHER_TOOL], + ) + print() + print(" --- batch per-chunk chunk.tools (with request_id routing) ---") + accumulated = {} + for chunk in resp: + self.assertIsInstance(chunk.tools, list) + rid = chunk.get("request_id", "?") + if chunk.tools: + for tc in chunk.tools: + self.assertIsInstance(tc, dict) + self.assertIn("index", tc) + idx = tc["index"] + key = (rid, idx) + entry = accumulated.setdefault(key, {"rid": rid, "args": ""}) + if "id" in tc: + entry["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"]: + entry["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + entry["args"] += tc["function"]["arguments"] + print(f" [{rid}] tools={chunk.tools}") + print() + print(" --- accumulated by (request_id, index) ---") + for key in sorted(accumulated.keys()): + e = accumulated[key] + print(f" [{e['rid']}][{key[1]}] id={e.get('id','?')} name={e.get('name','?')} args={e['args']}") + print() + print(" resp.tools:", dict(resp.tools)) + def test_batch_stream_with_tools(self): """batch 流式 + 工具调用:chunk.still 路由 + resp.tools""" prompts = ["北京的天气怎么样?用工具", "上海的天气怎么样?用工具"] @@ -308,6 +345,43 @@ def test_mixed_stream_repr(self): self.assertIn("request_1", resp.still) self.assertIn("request_2", resp.still) + def test_mixed_stream_chunk_tools(self): + # 混合 batch 中 stream=True 请求的 chunk.tools 逐帧输出 + resp = self.client.chat.batch( + requests=[ + {"prompt": "北京的天气", "stream": True, "tools": [WEATHER_TOOL]}, + {"prompt": "上海的天气", "stream": True, "tools": [WEATHER_TOOL]}, + {"prompt": "回答一个字:好", "stream": True}, + ], + ) + print() + print(" --- mixed batch per-chunk chunk.tools ---") + accumulated = {} + for chunk in resp: + self.assertIsInstance(chunk.tools, list) + rid = chunk.get("request_id", "?") + if chunk.tools: + for tc in chunk.tools: + idx = tc["index"] + key = (rid, idx) + entry = accumulated.setdefault(key, {"rid": rid, "args": ""}) + if "id" in tc: + entry["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"]: + entry["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + entry["args"] += tc["function"]["arguments"] + still_bit = f" still='{chunk.still}'" if chunk.still else "" + print(f" [{rid}] tools={chunk.tools}{still_bit}") + print() + print(" --- accumulated ---") + for key in sorted(accumulated.keys()): + e = accumulated[key] + print(f" [{e['rid']}][{key[1]}] id={e.get('id','?')} name={e.get('name','?')} args={e['args']}") + print() + print(" resp.tools:", dict(resp.tools)) + def test_mixed_stream_tools(self): """混合 batch + 工具:流式和非流式请求的工具调用都能取到""" resp = self.client.chat.batch( @@ -330,7 +404,66 @@ def test_mixed_stream_tools(self): self.assertIn(rid, resp.tools, f"工具请求 {rid} 应在 tools 中") - import asyncio +@unittest.skipUnless(API_KEY, "需要 API Key") +class TestBatchToolsFormatE2E(unittest.TestCase): + """验证批量 resp.tools 格式为 List[Dict]""" + + @classmethod + def setUpClass(cls): + from cnllm import CNLLM + cls.client = CNLLM(api_key=API_KEY, base_url=BASE_URL, model=MODEL) + + def test_non_stream_batch_tools_format(self): + """非流式批量 resp.tools[rid] 是 List[Dict],无 index""" + resp = self.client.chat.batch( + prompt=["北京的天气?", "上海的天气?"], + tools=[WEATHER_TOOL], + ) + for _ in resp: + pass + tools = dict(resp.tools) if resp.tools else {} + print(f"\n tools: {tools}") + for rid in ("request_0", "request_1"): + tc_list = tools.get(rid, []) + self.assertIsInstance(tc_list, list) + for tc in tc_list: + self.assertIsInstance(tc, dict) + self.assertNotIn("index", tc) + + def test_stream_batch_tools_format(self): + """流式批量 resp.tools[rid] 是 List[Dict],无 index""" + resp = self.client.chat.batch( + prompt=["北京的天气?", "上海的天气?"], + stream=True, + tools=[WEATHER_TOOL], + ) + for _ in resp: + pass + tools = dict(resp.tools) if resp.tools else {} + print(f"\n tools: {tools}") + for rid in ("request_0", "request_1"): + tc_list = tools.get(rid, []) + self.assertIsInstance(tc_list, list) + for tc in tc_list: + self.assertIsInstance(tc, dict) + self.assertNotIn("index", tc) + + def test_mixed_batch_tools_format(self): + """混合批量 resp.tools[rid] 是 List[Dict],无 index""" + resp = self.client.chat.batch( + requests=[ + {"prompt": "北京的天气", "stream": True, "tools": [WEATHER_TOOL]}, + {"prompt": "上海的天气", "tools": [WEATHER_TOOL]}, + ], + ) + for _ in resp: + pass + tools = dict(resp.tools) if resp.tools else {} + print(f"\n tools: {tools}") + for rid in ("request_0", "request_1"): + tc_list = tools.get(rid, []) + self.assertIsInstance(tc_list, list) + for tc in tc_list: + self.assertIsInstance(tc, dict) + self.assertNotIn("index", tc) - async def run(): - re \ No newline at end of file diff --git a/tests/key_needed/test_contextbox_chain.py b/tests/key_needed/test_contextbox_chain.py new file mode 100644 index 0000000..b2e2065 --- /dev/null +++ b/tests/key_needed/test_contextbox_chain.py @@ -0,0 +1,176 @@ +""" +ContextBox 多轮调用链测试 + +测试覆盖: + 1. 流式单链 — 工具调用 + ContextBox 构建上下文 + 2. 非流式单链 — 同上,非流式模式 + 3. 混合四链 — 非流式 → 流式 → 非流式 → 流式 + +需要有效 API Key。 +""" +import os +import sys +import json +import unittest +from dotenv import load_dotenv + +sys.stdout.reconfigure(encoding='utf-8') +load_dotenv() + +API_KEY = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY") +BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com") +MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-flash") + +WEATHER_TOOL = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + }, + "required": ["location"] + } + } +} + + +@unittest.skipUnless(API_KEY, "need API Key") +class TestContextBoxChain(unittest.TestCase): + + @classmethod + def setUpClass(cls): + from cnllm import CNLLM + cls.client = CNLLM(api_key=API_KEY, base_url=BASE_URL, model=MODEL) + + def _simulate_weather(self, tc): + args = json.loads(tc["function"]["arguments"]) + city = args.get("location", "") + data = { + "Beijing": "Beijing: 22C, sunny, humidity 40%", + "Shanghai": "Shanghai: 28C, cloudy, humidity 70%", + "Moscow": "Moscow: -5C, snow, humidity 85%", + } + result = data.get(city, city + ": 15C") + print(f" [execute_tool] {city} -> {result}") + return result + + # ---------------------------------------------------------------- + # Test 1: 流式单链 — 工具调用 + 上下文构建 + 模型读取上下文 + # ---------------------------------------------------------------- + def test_stream_chain(self): + from cnllm import ContextBox + + messages = [ + {"role": "user", + "content": "What is the weather in Beijing and Moscow?"} + ] + + print("=" * 60) + print("STREAM CHAIN: Turn 1 - Beijing + Moscow weather") + print("=" * 60) + + r1 = self.client.chat.create( + messages=messages, stream=True, tools=[WEATHER_TOOL]) + for _ in r1: + pass + print(f" still='{r1.still[:80] if r1.still else '(empty)'}'") + print(f" tools={r1.tools}") + + messages += ContextBox( + r1.still, r1.think, + r1.tools if r1.tools else None, + executor=self._simulate_weather, + ) + tool_msgs = [m for m in messages if m["role"] == "tool"] + self.assertEqual(len(tool_msgs), 2) + all_tc = " ".join(m["content"] for m in tool_msgs) + self.assertIn("Beijing", all_tc) + self.assertIn("Moscow", all_tc) + + print(f" ContextBox: {len(tool_msgs)} tool result(s) in context") + + print("\n" + "=" * 60) + print("STREAM CHAIN: Turn 2 - Ask difference") + print("(model MUST read tool results to answer)") + print("=" * 60) + + messages.append({"role": "user", + "content": "What is the temperature " + "difference between them?"}) + r2 = self.client.chat.create( + messages=messages, stream=True) + for _ in r2: + pass + print(f" still='{r2.still[:120] if r2.still else '(empty)'}'") + self.assertGreater(len(r2.still) + len(r2.tools), 0) + if r2.still: + self.assertIn("Beijing", r2.still) + self.assertIn("Moscow", r2.still) + print(" PASS: Stream chain works\n") + + # ---------------------------------------------------------------- + # Test 2: 非流式单链 + # ---------------------------------------------------------------- + def test_nonstream_chain(self): + from cnllm import ContextBox + + messages = [{"role": "user", + "content": "What is the weather in Shanghai?"}] + + print("=" * 60) + print("NON-STREAM CHAIN: Turn 1 - Shanghai weather") + print("=" * 60) + + r1 = self.client.chat.create( + messages=messages, tools=[WEATHER_TOOL]) + print(f" still='{r1.still[:80] if r1.still else '(empty)'}'") + print(f" tools={r1.tools}") + + messages += ContextBox( + r1.still, r1.think, + r1.tools if r1.tools else None, + executor=self._simulate_weather, + ) + tool_msgs = [m for m in messages if m["role"] == "tool"] + self.assertEqual(len(tool_msgs), 1) if r1.tools else None + print(f" ContextBox: {len(tool_msgs)} tool result(s) in context") + + print("\n" + "=" * 60) + print("NON-STREAM CHAIN: Turn 2 - Follow up") + print("=" * 60) + + messages.append({"role": "user", + "content": "Should I bring an umbrella today?"}) + r2 = self.client.chat.create( + messages=messages, tools=[WEATHER_TOOL]) + print(f" still='{r2.still[:120] if r2.still else '(empty)'}'") + self.assertGreater(len(r2.still) + len(r2.tools), 0) + print(" PASS: Non-stream chain works\n") + + # ---------------------------------------------------------------- + # Test 3: 混合四链 — 非流式 → 流式 → 非流式 → 流式 + # ---------------------------------------------------------------- + def test_mixed_chain(self): + from cnllm import ContextBox + + messages = [{"role": "user", + "content": "What is the weather in Beijing?"}] + + def turn(n, mode, label, stream, append_q=None): + nonlocal messages + print(f"\n{'='*60}") + print(f"TURN {n} [{mode}]: {label}") + print(f"{'='*60}") + if append_q: + messages.append({"role": "user", "content": append_q}) + print(f" messages in context: {len(messages)}") + + resp = self.client.chat.create( + messages=messages, stream=stream, tools=[WEATHER_TOOL]) + if stream: + for _ in resp: + pass + \ No newline at end of file diff --git a/tests/key_needed/test_field_accumulation.py b/tests/key_needed/test_field_accumulation.py index daef785..1607892 100644 --- a/tests/key_needed/test_field_accumulation.py +++ b/tests/key_needed/test_field_accumulation.py @@ -765,9 +765,9 @@ def test_stream_tools_accumulation(self): print("\n=== 核心逻辑核查点 ===") checks = [] - is_dict = isinstance(final_tools, dict) - checks.append(("1. .tools 类型是 dict", is_dict, f"类型={type(final_tools).__name__}")) - print(f"\n 核查 1: .tools 类型 - {'✓ PASS' if is_dict else '✗ FAIL'}") + is_list = isinstance(final_tools, list) + checks.append(("1. .tools 类型是 list", is_list, f"类型={type(final_tools).__name__}")) + print(f"\n 核查 1: .tools 类型 - {'✓ PASS' if is_list else '✗ FAIL'}") has_tools = final_tools is not None and len(final_tools) > 0 checks.append(("2. .tools 不为空 (有工具调用)", has_tools, f"长度={len(final_tools) if final_tools else 0}")) diff --git a/tests/key_needed/test_stream_chunk_live_real.py b/tests/key_needed/test_stream_chunk_live_real.py index 66204e1..f391aa8 100644 --- a/tests/key_needed/test_stream_chunk_live_real.py +++ b/tests/key_needed/test_stream_chunk_live_real.py @@ -141,6 +141,63 @@ def test_live_dict_context_manager(self): # 流结束后正常访问属性 self.assertGreater(len(resp.still), 0) + def test_repr_with_chunk_tools(self): + # resp.repr + chunk.tools incremental observation + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } + ] + messages = [{"role": "user", "content": "\u7528\u5de5\u5177\u67e5\u5317\u4eac\u7684\u5929\u6c14"}] + resp = self.client.chat.create( + messages=messages, + model=MODEL, + stream=True, + tools=tools, + ) + print() + print(" --- repr live + chunk.tools per frame ---") + accumulated = {} + with resp.repr as view: + for i, chunk in enumerate(resp): + view.refresh() + if chunk.tools: + for tc in chunk.tools: + idx = tc["index"] + entry = accumulated.setdefault(idx, {"args": ""}) + if "id" in tc: + entry["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"]: + entry["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + entry["args"] += tc["function"]["arguments"] + parts = [] + if chunk.still: + parts.append("still='" + chunk.still + "'") + if chunk.think: + parts.append("think='" + chunk.think + "'") + if chunk.tools: + parts.append("tools=" + str(chunk.tools)) + if parts: + print(" chunk[" + str(i).zfill(2) + "]: " + ", ".join(parts)) + print() + print(" --- accumulated from chunk.tools ---") + for idx, entry in accumulated.items(): + print(" [" + str(idx) + "] id=" + str(entry.get('id','?')) + " name=" + str(entry.get('name','?')) + " args=" + entry['args']) + print(" resp.still:", resp.still) + print(" resp.tools:", resp.tools) + def test_live_dict_multiple_requests(self): """连续多次 live dict 调用""" for i in range(3): @@ -253,10 +310,109 @@ def test_stream_chunk_tool_calls_in_dict(self): full_tools = resp.tools if full_tools: print(f" resp.tools has {len(full_tools)} tool call(s)") - for idx, tc in full_tools.items(): + for idx, tc in enumerate(full_tools): print(f" [{idx}] name={tc.get('function', {}).get('name', '?')}, " f"args={tc.get('function', {}).get('arguments', '')[:80]}") + def test_chunk_tools_property(self): + # chunk.tools returns per-chunk incremental tool_calls + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } + ] + messages = [{"role": "user", "content": "北京的天气?用工具"}] + resp = self.client.chat.create( + messages=messages, + model=MODEL, + stream=True, + tools=tools, + ) + print() + print(" --- per-chunk chunk.tools incremental ---") + chunk_index = 0 + accumulated = {} + for chunk in resp: + chunk_index += 1 + self.assertIsInstance(chunk.tools, list) + if chunk.tools: + for tc in chunk.tools: + self.assertIsInstance(tc, dict) + self.assertIn("index", tc) + idx = tc["index"] + entry = accumulated.setdefault(idx, {"args": ""}) + if "id" in tc: + entry["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"]: + entry["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + entry["args"] += tc["function"]["arguments"] + line = f" chunk[{chunk_index:02d}] tools={chunk.tools}" + if chunk.still: + line += f" still='{chunk.still}'" + print(line) + print() + print(" --- accumulated by index ---") + for idx, entry in accumulated.items(): + print(f" [{idx}] id={entry.get('id','?')} name={entry.get('name','?')} args={entry['args']}") + print() + print(" resp.tools (final):", resp.tools) + + def test_chunk_tools_type_with_still_think(self): + # chunk.tools / chunk.still / chunk.think type compatibility with output + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } + ] + messages = [{"role": "user", "content": "用工具查北京天气"}] + resp = self.client.chat.create( + messages=messages, + model=MODEL, + stream=True, + tools=tools, + ) + print() + print(" --- chunk.still / chunk.think / chunk.tools per chunk ---") + for i, chunk in enumerate(resp): + self.assertIsInstance(chunk.still, str) + self.assertIsInstance(chunk.think, str) + self.assertIsInstance(chunk.tools, list) + if chunk.tools: + self.assertIsInstance(chunk.tools[0], dict) + parts = [] + if chunk.still: + parts.append(f"still='{chunk.still}'") + if chunk.think: + parts.append(f"think='{chunk.think}'") + if chunk.tools: + parts.append(f"tools={chunk.tools}") + if parts: + print(f" chunk[{i:02d}]: " + ", ".join(parts)) + print() + print(" resp.tools:", resp.tools) + def test_repr_with_tools(self): """resp.repr 在工具调用场景下不报错""" tools = [ diff --git a/tests/test_batch_chunk_repr.py b/tests/test_batch_chunk_repr.py index c891b7c..46ce864 100644 --- a/tests/test_batch_chunk_repr.py +++ b/tests/test_batch_chunk_repr.py @@ -134,6 +134,154 @@ def test_streamchunk_with_request_id(self): self.assertIn("request_id", sc) +class TestBatchStreamChunkTools(unittest.TestCase): + """batch 流式 + 混合流式 chunk.tools 兼容""" + + def test_batch_stream_chunk_tools(self): + """batch 流式:含 request_id 的 StreamChunk 上 .tools 正常""" + from cnllm.core.accumulators.single_accumulator import StreamChunk + chunk = StreamChunk({ + "request_id": "request_0", + "choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}} + ]}}] + }) + self.assertEqual(chunk["request_id"], "request_0") + self.assertEqual(len(chunk.tools), 1) + self.assertEqual(chunk.tools[0]["id"], "call_1") + + def test_batch_from_chunks_tools(self): + """StreamAccumulator.from_chunks() 批量工具调用""" + from cnllm.core.accumulators.single_accumulator import StreamAccumulator + chunks = [ + {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "get_weather", "arguments": ""}} + ]}}]}, + {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "function": {"arguments": "{\"city\":\"北京\"}"}} + ]}}]}, + {"choices": [{"index": 0, "delta": {"content": "正在查询"}}]}, + ] + acc = StreamAccumulator.from_chunks(chunks) + results = [] + for chunk in acc: + results.append({ + "still": chunk.still, + "tools": chunk.tools, + }) + self.assertEqual(len(results), 3) + # 首帧:完整工具元数据 + self.assertEqual(len(results[0]["tools"]), 1) + self.assertEqual(results[0]["tools"][0]["id"], "call_1") + self.assertEqual(results[0]["tools"][0]["function"]["name"], "get_weather") + # 第二帧:仅 arguments 增量 + self.assertEqual(len(results[1]["tools"]), 1) + self.assertNotIn("id", results[1]["tools"][0]) + # 第三帧:无工具调用 + self.assertEqual(results[2]["tools"], []) + self.assertEqual(results[2]["still"], "正在查询") + + def test_mixed_batch_chunk_tools(self): + """混合 batch 中,stream=True 的请求 yield 的 chunk 含 .tools""" + from cnllm.core.accumulators.single_accumulator import StreamChunk + + # 模拟 MixedStreamAccumulator yield 的两种 chunk 形态 + # 形态 A:流式请求的 tool_calls chunk + stream_chunk = StreamChunk({ + "request_id": "request_0", + "choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}} + ]}}] + }) + + # 形态 B:非流式请求的 marker chunk(空 delta) + marker_chunk = StreamChunk({ + "request_id": "request_1", + "choices": [{"delta": {}}], + "_state": "completed", + }) + + self.assertEqual(len(stream_chunk.tools), 1) + self.assertEqual(stream_chunk.tools[0]["id"], "call_1") + self.assertEqual(marker_chunk.tools, []) + + + + +class TestBatchToolsFormat(unittest.TestCase): + """验证批量路径下 _tools[rid] 统一为 List[Dict]""" + + def test_set_tools_list(self): + """set_tools 存储 List[Dict] 后 .tools 保持 List[Dict]""" + br = BatchResponse() + br.set_tools("request_0", [ + {"id": "call_1", "function": {"name": "get_weather"}}, + ]) + # 内部存储应该是 List[Dict] + self.assertIsInstance(br._tools["request_0"], list) + self.assertEqual(len(br._tools["request_0"]), 1) + # 外部读取也应保持 List[Dict] + tools = br.tools + self.assertIn("request_0", tools) + self.assertIsInstance(tools["request_0"], list) + self.assertEqual(tools["request_0"][0]["id"], "call_1") + + def test_set_tools_empty(self): + """空工具列表""" + br = BatchResponse() + br.set_tools("request_0", []) + self.assertEqual(br._tools["request_0"], []) + self.assertEqual(br.tools["request_0"], []) + + def test_multiple_requests(self): + """多条请求各自独立""" + br = BatchResponse() + br.set_tools("r0", [{"id": "c1"}]) + br.set_tools("r1", [{"id": "c2"}, {"id": "c3"}]) + self.assertEqual(len(br.tools), 2) + self.assertEqual(br.tools["r0"][0]["id"], "c1") + self.assertEqual(len(br.tools["r1"]), 2) + + def test_merge_tools_into_list(self): + """模拟流式 batch 的场景:增量 chunk 在 list 中按 index 归并""" + from cnllm.core.accumulators.single_accumulator import ToolCollector + + # 模拟 BatchStreamAccumulator 的合并逻辑(现在存储为 List[Dict]) + existing = [] + chunks = [ + [{"index": 0, "id": "call_1", "function": {"name": "get_weather"}}], + [{"index": 0, "function": {"arguments": '{"city":'}}], + [{"index": 0, "function": {"arguments": '"Beijing"'}}], + ] + for chunk_tools in chunks: + for tc in chunk_tools: + idx = tc.get("index") + found = False + for i, et in enumerate(existing): + if et.get("index") == idx: + from unittest.mock import MagicMock + # Simplified merge: just update + existing[i].update(tc) + if "function" in tc and "function" in existing[i]: + existing[i]["function"].update(tc["function"]) + found = True + break + if not found: + existing.append(dict(tc)) + + br = BatchResponse() + br.set_tools("request_0", existing) + + self.assertIsInstance(br._tools["request_0"], list) + tools = br.tools["request_0"] + self.assertEqual(len(tools), 1) + self.assertEqual(tools[0]["id"], "call_1") + self.assertIn("Beijing", tools[0].get("function", {}).get("arguments", "")) + print(" merge test: OK") + class TestIndexableDict(unittest.TestCase): """IndexableDict 的 dict() 转换""" diff --git a/tests/test_stream_chunk.py b/tests/test_stream_chunk.py index 60cbff0..4d2b642 100644 --- a/tests/test_stream_chunk.py +++ b/tests/test_stream_chunk.py @@ -1,9 +1,22 @@ """ StreamChunk 单元测试(使用 unittest) """ -import json -import unittest -from cnllm.core.accumulators.single_accumulator import StreamChunk +import sys, types, json, unittest + +# mock httpx(避免 cnllm 包导入时触发) +httpx = types.ModuleType('httpx') +httpx.Client = type('C', (), {'__init__': lambda s, **kw: None, '__enter__': lambda s: s, '__exit__': lambda s, *a: None, 'post': lambda s, **kw: type('R', (), {'status_code': 200, 'raise_for_status': lambda s: None, 'json': lambda s: {}})()}) +httpx.AsyncClient = type('A', (), {'__init__': lambda s, **kw: None, 'post': lambda s, **kw: type('R', (), {'status_code': 200})()}) +httpx.Timeout = lambda *a, **kw: None +httpx.Limits = lambda *a, **kw: None +httpx.Response = type('R', (), {'status_code': 200, 'text': ''}) +sys.modules['httpx'] = httpx + +# mock dotenv +sys.modules['dotenv'] = types.ModuleType('dotenv') +sys.modules['dotenv'].load_dotenv = lambda *a, **kw: None + +from cnllm.core.accumulators.single_accumulator import StreamChunk, ToolCollector class TestStreamChunkDictCompatibility(unittest.TestCase): @@ -117,6 +130,85 @@ def test_only_still(self): self.assertEqual(chunk.think, "") +class TestStreamChunkTools(unittest.TestCase): + """chunk.tools 返回 delta.tool_calls 列表""" + + def test_tools_basic(self): + chunk = StreamChunk({"choices": [{"delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "get_weather", "arguments": ""}} + ]}}]}) + tools = chunk.tools + self.assertIsInstance(tools, list) + self.assertEqual(len(tools), 1) + self.assertEqual(tools[0]["index"], 0) + self.assertEqual(tools[0]["id"], "call_1") + self.assertEqual(tools[0]["function"]["name"], "get_weather") + + def test_tools_no_key(self): + chunk = StreamChunk({"choices": [{"delta": {"content": "你好"}}]}) + self.assertEqual(chunk.tools, []) + + def test_tools_empty_list(self): + chunk = StreamChunk({"choices": [{"delta": {"tool_calls": []}}]}) + self.assertEqual(chunk.tools, []) + + def test_tools_missing_choices(self): + chunk = StreamChunk({"id": "x"}) + self.assertEqual(chunk.tools, []) + + def test_tools_empty_choices(self): + chunk = StreamChunk({"choices": []}) + self.assertEqual(chunk.tools, []) + + def test_tools_none_value(self): + chunk = StreamChunk({"choices": [{"delta": {"tool_calls": None}}]}) + self.assertEqual(chunk.tools, []) + + def test_tools_multiple_indices(self): + chunk = StreamChunk({"choices": [{"delta": {"tool_calls": [ + {"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": "{\"city\":\"北京\"}"}}, + {"index": 1, "id": "call_2", + "function": {"name": "get_air_quality", "arguments": "{\"city\":\"北京\"}"}}, + ]}}]}) + self.assertEqual(len(chunk.tools), 2) + self.assertEqual(chunk.tools[0]["id"], "call_1") + self.assertEqual(chunk.tools[1]["id"], "call_2") + + def test_tools_partial_args(self): + """逐帧增量:仅携带 arguments,无 id/name""" + chunk = StreamChunk({"choices": [{"delta": {"tool_calls": [ + {"index": 0, "function": {"arguments": "{\"city\":"}} + ]}}]}) + self.assertEqual(len(chunk.tools), 1) + self.assertNotIn("id", chunk.tools[0]) + self.assertEqual(chunk.tools[0]["function"]["arguments"], "{\"city\":") + + def test_tools_with_content_and_think(self): + """与 content / reasoning_content 共存于同一 chunk""" + chunk = StreamChunk({"choices": [{"delta": { + "content": "北京", + "reasoning_content": "好的", + "tool_calls": [{"index": 0, "function": {"arguments": "{\"city\":"}}] + }}]}) + self.assertEqual(chunk.still, "北京") + self.assertEqual(chunk.think, "好的") + self.assertEqual(len(chunk.tools), 1) + self.assertEqual(chunk.tools[0]["function"]["arguments"], "{\"city\":") + + def test_tools_dict_access_preserved(self): + """dict 接口与 .tools 属性一致""" + data = {"choices": [{"delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "function": {"name": "get_weather"}} + ]}}]} + chunk = StreamChunk(data) + self.assertEqual(len(chunk.tools), 1) + self.assertEqual(chunk.tools[0]["id"], "call_1") + # dict 全等对比 + self.assertIs(chunk["choices"][0]["delta"]["tool_calls"], chunk.tools) + + class TestStreamChunkEdgeCases(unittest.TestCase): """边界情况""" @@ -155,5 +247,65 @@ def test_mutability(self): self.assertEqual(data["choices"][0]["delta"]["content"], "世界") -if __name__ == "__main__": - unittest.main() + + +class TestToolCollector(unittest.TestCase): + """ToolCollector: incremental tool_calls merge""" + + def test_single_tool_full(self): + col = ToolCollector() + col.update([{"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}}]) + self.assertEqual(col.all, {0: {"args": "", "id": "call_1", "name": "get_weather"}}) + self.assertEqual(col[0]["id"], "call_1") + print(" single full: OK") + + def test_multi_chunk_same_index(self): + col = ToolCollector() + col.update([{"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}}]) + col.update([{"index": 0, "function": {"arguments": '{"city":'}}]) + col.update([{"index": 0, "function": {"arguments": '"Beijing"'}}]) + col.update([{"index": 0, "function": {"arguments": "}"}}]) + self.assertEqual(col[0]["args"], '{"city":"Beijing"}') + self.assertEqual(col[0]["id"], "call_1") + print(" same index merge: OK") + + def test_two_indices_same_chunk(self): + col = ToolCollector() + col.update([ + {"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}}, + {"index": 1, "id": "call_2", + "function": {"name": "get_air_quality", "arguments": ""}}, + ]) + self.assertEqual(len(col.all), 2) + self.assertEqual(col[0]["name"], "get_weather") + self.assertEqual(col[1]["name"], "get_air_quality") + print(" two indices: OK") + + def test_empty_update(self): + col = ToolCollector() + col.update([]) + self.assertEqual(col.all, {}) + col.update([]) + self.assertEqual(col.all, {}) + print(" empty: OK") + + def test_minimal_fields(self): + col = ToolCollector() + col.update([{"index": 0, "function": {"arguments": "test"}}]) + self.assertNotIn("id", col[0]) + self.assertEqual(col[0]["args"], "test") + print(" min fields: OK") + + def test_all_returns_latest_state(self): + col = ToolCollector() + col.update([{"index": 0, "id": "call_1", + "function": {"name": "get_weather", "arguments": ""}}]) + self.assertEqual(col[0]["args"], "") + col.update([{"index": 0, "function": {"arguments": "data"}}]) + self.assertEqual(col[0]["args"], "data") + print(" state accumulation: OK") + + diff --git a/tests/test_stream_chunk_iteration.py b/tests/test_stream_chunk_iteration.py index 001c9f2..12d9fef 100644 --- a/tests/test_stream_chunk_iteration.py +++ b/tests/test_stream_chunk_iteration.py @@ -1,8 +1,21 @@ """ StreamAccumulator 迭代 yield StreamChunk 的 mock 测试 """ -import unittest +import sys, types, unittest from unittest.mock import MagicMock, AsyncMock, patch + +# mock httpx(避免 cnllm 包导入时触发) +httpx = types.ModuleType('httpx') +httpx.Client = type('C', (), {'__init__': lambda s, **kw: None, '__enter__': lambda s: s, '__exit__': lambda s, *a: None, 'post': lambda s, **kw: type('R', (), {'status_code': 200, 'raise_for_status': lambda s: None, 'json': lambda s: {}})()}) +httpx.AsyncClient = type('A', (), {'__init__': lambda s, **kw: None, 'post': lambda s, **kw: type('R', (), {'status_code': 200})()}) +httpx.Timeout = lambda *a, **kw: None +httpx.Limits = lambda *a, **kw: None +httpx.Response = type('R', (), {'status_code': 200, 'text': ''}) +sys.modules['httpx'] = httpx + +sys.modules['dotenv'] = types.ModuleType('dotenv') +sys.modules['dotenv'].load_dotenv = lambda *a, **kw: None + from cnllm.core.accumulators.single_accumulator import ( StreamAccumulator, AsyncStreamAccumulator, StreamChunk ) @@ -25,6 +38,27 @@ def _accumulate_extra_fields(self, result): reasoning = delta.get("reasoning_content") or "" if reasoning: self._cnllm_extra["_thinking"] = self._cnllm_extra.get("_thinking", "") + reasoning + tool_calls = delta.get("tool_calls") + if tool_calls: + if "_tools" not in self._cnllm_extra: + self._cnllm_extra["_tools"] = {} + tools_dict = self._cnllm_extra["_tools"] + for tc in tool_calls: + idx = tc.get("index", len(tools_dict)) + if idx in tools_dict: + existing = tools_dict[idx] + for k, v in tc.items(): + if k == "function" and isinstance(v, dict) and "function" in existing: + fn_existing = existing["function"] + for fk, fv in v.items(): + if fk == "arguments" and "arguments" in fn_existing: + fn_existing["arguments"] += fv + else: + fn_existing[fk] = fv + else: + existing[k] = v + else: + tools_dict[idx] = dict(tc) class TestStreamAccumulatorYieldsStreamChunk(unittest.TestCase): @@ -77,6 +111,85 @@ def test_empty_iterator(self): chunks = list(accumulator) self.assertEqual(len(chunks), 0) + # ---- tool_calls ---- + + def test_tools_single_chunk(self): + """单 chunk 携带完整 tool_calls""" + raw = [{"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "get_weather", "arguments": ""}} + ]}}]}] + adapter = MockAdapter() + accumulator = StreamAccumulator(iter(raw), adapter) + chunks = list(accumulator) + self.assertEqual(len(chunks), 1) + self.assertEqual(len(chunks[0].tools), 1) + self.assertEqual(chunks[0].tools[0]["id"], "call_1") + self.assertEqual(chunks[0].tools[0]["function"]["name"], "get_weather") + + def test_tools_multiple_chunks_same_index(self): + """同一 index 跨 chunk 累积:首帧 id/name,后续仅 arguments""" + raw = [ + {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "get_weather", "arguments": ""}} + ]}}]}, + {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "function": {"arguments": "{\"city\":"}} + ]}}]}, + {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "function": {"arguments": "\"北京\"}"}} + ]}}]}, + ] + adapter = MockAdapter() + accumulator = StreamAccumulator(iter(raw), adapter) + chunks = list(accumulator) + self.assertEqual(len(chunks), 3) + # 首帧:完整元数据 + self.assertEqual(chunks[0].tools[0]["id"], "call_1") + self.assertEqual(chunks[0].tools[0]["function"]["name"], "get_weather") + # 后续帧:仅 arguments,filter_stream_chunk 已剥离 id/name + self.assertNotIn("id", chunks[1].tools[0]) + self.assertEqual(chunks[1].tools[0]["function"]["arguments"], "{\"city\":") + self.assertEqual(chunks[2].tools[0]["function"]["arguments"], "\"北京\"}") + + def test_tools_two_indices_same_chunk(self): + """同一 chunk 两个 index 同时到达""" + raw = [{"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "function": {"name": "get_weather", "arguments": ""}}, + {"index": 1, "id": "call_2", "function": {"name": "get_air_quality", "arguments": ""}}, + ]}}]}] + adapter = MockAdapter() + accumulator = StreamAccumulator(iter(raw), adapter) + chunks = list(accumulator) + self.assertEqual(len(chunks), 1) + self.assertEqual(len(chunks[0].tools), 2) + self.assertEqual(chunks[0].tools[0]["id"], "call_1") + self.assertEqual(chunks[0].tools[1]["id"], "call_2") + + def test_tools_still_think_same_chunk(self): + """tool_calls 与 content / reasoning_content 同 chunk""" + raw = [{"choices": [{"index": 0, "delta": { + "content": "北京", + "reasoning_content": "好的", + "tool_calls": [{"index": 0, "function": {"arguments": "{\"city\":"}}] + }}]}] + adapter = MockAdapter() + accumulator = StreamAccumulator(iter(raw), adapter) + chunk = list(accumulator)[0] + self.assertEqual(chunk.still, "北京") + self.assertEqual(chunk.think, "好的") + self.assertEqual(len(chunk.tools), 1) + + def test_tools_no_tool_calls(self): + """无 tool_calls 的 chunk""" + raw = [{"choices": [{"index": 0, "delta": {"content": "你好"}}]}] + adapter = MockAdapter() + accumulator = StreamAccumulator(iter(raw), adapter) + chunk = list(accumulator)[0] + self.assertEqual(chunk.tools, []) + self.assertEqual(chunk.still, "你好") + def test_finish_reason_chunk_is_streamchunk(self): accumulator = StreamAccumulator(iter(self.raw_chunks), self.adapter) last_chunk = list(accumulator)[-1]