From 3ae142065f96b76802b5b57e462b09103d3862d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:05:21 +0800 Subject: [PATCH 01/12] Harden OpenAI-compatible providers and FAISS wrappers - tolerate raw string and dict completions from compatible providers - sanitize malformed tool calls before agent execution - support low-level FAISS SWIG add/search signatures - skip unreadable distribution metadata in dependency scans --- .../db/vec_db/faiss_impl/embedding_storage.py | 72 ++++-- astrbot/core/provider/entities.py | 128 ++++++----- .../core/provider/sources/openai_source.py | 206 ++++++++++-------- astrbot/core/utils/requirements_utils.py | 29 ++- 4 files changed, 264 insertions(+), 171 deletions(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index dc6977cf8a..19163278f6 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -2,7 +2,7 @@ import faiss except ModuleNotFoundError: raise ImportError( - "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", + "faiss ??????? 'pip install faiss-cpu' ? 'pip install faiss-gpu' ???", ) import os @@ -20,62 +20,90 @@ def __init__(self, dimension: int, path: str | None = None) -> None: base_index = faiss.IndexFlatL2(dimension) self.index = faiss.IndexIDMap(base_index) + def _add_with_ids(self, vectors: np.ndarray, ids: np.ndarray) -> None: + assert self.index is not None, "FAISS index is not initialized." + vectors = np.ascontiguousarray(vectors, dtype=np.float32) + ids = np.ascontiguousarray(ids, dtype=np.int64) + try: + self.index.add_with_ids(vectors, ids) + except TypeError as exc: + if "missing" not in str(exc): + raise + self.index.add_with_ids( + vectors.shape[0], + faiss.swig_ptr(vectors), + faiss.swig_ptr(ids), + ) + async def insert(self, vector: np.ndarray, id: int) -> None: - """插入向量 + """???? Args: - vector (np.ndarray): 要插入的向量 - id (int): 向量的ID + vector (np.ndarray): ?????? + id (int): ???ID Raises: - ValueError: 如果向量的维度与存储的维度不匹配 + ValueError: ???????????????? """ assert self.index is not None, "FAISS index is not initialized." if vector.shape[0] != self.dimension: raise ValueError( - f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}", + f"???????, ??: {self.dimension}, ??: {vector.shape[0]}", ) - self.index.add_with_ids(vector.reshape(1, -1), np.array([id])) + self._add_with_ids(vector.reshape(1, -1), np.array([id], dtype=np.int64)) await self.save_index() async def insert_batch(self, vectors: np.ndarray, ids: list[int]) -> None: - """批量插入向量 + """?????? Args: - vectors (np.ndarray): 要插入的向量数组 - ids (list[int]): 向量的ID列表 + vectors (np.ndarray): ???????? + ids (list[int]): ???ID?? Raises: - ValueError: 如果向量的维度与存储的维度不匹配 + ValueError: ???????????????? """ assert self.index is not None, "FAISS index is not initialized." if vectors.shape[1] != self.dimension: raise ValueError( - f"向量维度不匹配, 期望: {self.dimension}, 实际: {vectors.shape[1]}", + f"???????, ??: {self.dimension}, ??: {vectors.shape[1]}", ) - self.index.add_with_ids(vectors, np.array(ids)) + self._add_with_ids(vectors, np.array(ids, dtype=np.int64)) await self.save_index() async def search(self, vector: np.ndarray, k: int) -> tuple: - """搜索最相似的向量 + """???????? Args: - vector (np.ndarray): 查询向量 - k (int): 返回的最相似向量的数量 + vector (np.ndarray): ???? + k (int): ??????????? Returns: - tuple: (距离, 索引) + tuple: (??, ??) """ assert self.index is not None, "FAISS index is not initialized." faiss.normalize_L2(vector) - distances, indices = self.index.search(vector, k) + try: + distances, indices = self.index.search(vector, k) + except TypeError as exc: + if "missing 3 required positional arguments" not in str(exc): + raise + distances = np.empty((vector.shape[0], k), dtype=np.float32) + indices = np.empty((vector.shape[0], k), dtype=np.int64) + self.index.search( + vector.shape[0], + faiss.swig_ptr(vector), + k, + faiss.swig_ptr(distances), + faiss.swig_ptr(indices), + ) return distances, indices async def delete(self, ids: list[int]) -> None: - """删除向量 + """???? Args: - ids (list[int]): 要删除的向量ID列表 + ids (list[int]): ??????ID?? """ assert self.index is not None, "FAISS index is not initialized." @@ -84,10 +112,10 @@ async def delete(self, ids: list[int]) -> None: await self.save_index() async def save_index(self) -> None: - """保存索引 + """???? Args: - path (str): 保存索引的路径 + path (str): ??????? """ if self.index is None: diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 8e12683ffb..00f681a324 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -67,12 +67,12 @@ class ProviderMetaData(ProviderMeta): @dataclass class ToolCallsResult: - """工具调用结果""" + """??????""" tool_calls_info: AssistantMessageSegment - """函数调用的信息""" + """???????""" tool_calls_result: list[ToolCallMessageSegment] - """函数调用的结果""" + """???????""" def to_openai_messages(self) -> list[dict]: ret = [ @@ -93,30 +93,30 @@ def to_openai_messages_model( @dataclass class ProviderRequest: prompt: str | None = None - """提示词""" + """???""" session_id: str | None = "" - """会话 ID""" + """?? ID""" image_urls: list[str] = field(default_factory=list) - """图片 URL 列表""" + """?? URL ??""" audio_urls: list[str] = field(default_factory=list) - """音频 URL 列表,也支持本地路径""" + """?? URL ??????????""" extra_user_content_parts: list[ContentPart] = field(default_factory=list) - """额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象""" + """???????????????????????????????????????????? dict ? ContentPart ??""" func_tool: ToolSet | None = None - """可用的函数工具""" + """???????""" contexts: list[dict] = field(default_factory=list) """ - OpenAI 格式上下文列表。 - 参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages + OpenAI ???????? + ?? https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages """ system_prompt: str = "" - """系统提示词""" + """?????""" conversation: Conversation | None = None - """关联的对话对象""" + """???????""" tool_calls_result: list[ToolCallsResult] | ToolCallsResult | None = None - """附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls""" + """??????????????????: https://platform.openai.com/docs/guides/function-calling#handling-function-calls""" model: str | None = None - """模型名称,为 None 时使用提供商的默认模型""" + """?????? None ???????????""" def __repr__(self) -> str: return ( @@ -133,7 +133,7 @@ def __str__(self) -> str: return self.__repr__() def append_tool_calls_result(self, tool_calls_result: ToolCallsResult) -> None: - """添加工具调用结果到请求中""" + """????????????""" if not self.tool_calls_result: self.tool_calls_result = [] if isinstance(self.tool_calls_result, ToolCallsResult): @@ -141,7 +141,7 @@ def append_tool_calls_result(self, tool_calls_result: ToolCallsResult) -> None: self.tool_calls_result.append(tool_calls_result) def _print_friendly_context(self): - """打印友好的消息上下文。将多模态内容折叠为简短标记。""" + """?????????????????????????""" if not self.contexts: return ( f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}, " @@ -189,26 +189,26 @@ def _print_friendly_context(self): return "\n".join(result_parts) async def assemble_context(self) -> dict: - """将请求(prompt、image_urls 和 audio_urls)包装成统一消息格式。""" - # 构建内容块列表 + """???(prompt?image_urls ? audio_urls)??????????""" + # ??????? content_blocks = [] - # 1. 用户原始发言(OpenAI 建议:用户发言在前) + # 1. ???????OpenAI ?????????? if self.prompt and self.prompt.strip(): content_blocks.append({"type": "text", "text": self.prompt}) elif self.image_urls: - # 如果没有文本但有图片,添加占位文本 - content_blocks.append({"type": "text", "text": "[图片]"}) + # ????????????????? + content_blocks.append({"type": "text", "text": "[??]"}) elif self.audio_urls: - # 如果没有文本但有音频,添加占位文本 - content_blocks.append({"type": "text", "text": "[音频]"}) + # ????????????????? + content_blocks.append({"type": "text", "text": "[??]"}) - # 2. 额外的内容块(系统提醒、指令等) + # 2. ???????????????? if self.extra_user_content_parts: for part in self.extra_user_content_parts: content_blocks.append(part.model_dump_for_context()) - # 3. 图片内容 + # 3. ???? if self.image_urls: for image_url in self.image_urls: if image_url.startswith("http"): @@ -220,13 +220,13 @@ async def assemble_context(self) -> dict: else: image_data = await self._encode_image_bs64(image_url) if not image_data: - logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") + logger.warning(f"?? {image_url} ????????????") continue content_blocks.append( {"type": "image_url", "image_url": {"url": image_data}}, ) - # 4. 音频内容 + # 4. ???? if self.audio_urls: for audio_url in self.audio_urls: if audio_url.startswith("http"): @@ -264,13 +264,13 @@ async def assemble_context(self) -> dict: source_ref=audio_url, ) if not audio_data: - logger.warning(f"音频 {audio_url} 得到的结果为空,将忽略。") + logger.warning(f"?? {audio_url} ????????????") continue content_blocks.append( {"type": "audio_url", "audio_url": {"url": audio_data}}, ) - # 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容 + # ????????? prompt ????????????????????????????? if ( len(content_blocks) == 1 and content_blocks[0]["type"] == "text" @@ -280,11 +280,11 @@ async def assemble_context(self) -> dict: ): return {"role": "user", "content": content_blocks[0]["text"]} - # 否则返回多模态格式 + # ????????? return {"role": "user", "content": content_blocks} async def _encode_image_bs64(self, image_url: str) -> str: - """将图片转换为 base64""" + """?????? base64""" if image_url.startswith("base64://"): return image_url.replace("base64://", "data:image/jpeg;base64,") with open(image_url, "rb") as f: @@ -296,7 +296,7 @@ async def _encode_audio_bs64( audio_path: str, source_ref: str | None = None, ) -> str: - """将音频转换为 base64""" + """?????? base64""" mime_type = "audio/wav" if audio_path.startswith("base64://"): @@ -393,15 +393,15 @@ def __init__( id: str | None = None, usage: TokenUsage | None = None, ) -> None: - """初始化 LLMResponse + """??? LLMResponse Args: - role (str): 角色, assistant, tool, err - completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "". - result_chain (MessageChain, optional): 返回的消息链. Defaults to None. - tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None. - tools_call_name (List[str], optional): 工具调用名称. Defaults to None. - raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None. + role (str): ??, assistant, tool, err + completion_text (str, optional): ????????????????? result_chain. Defaults to "". + result_chain (MessageChain, optional): ??????. Defaults to None. + tools_call_args (List[Dict[str, any]], optional): ??????. Defaults to None. + tools_call_name (List[str], optional): ??????. Defaults to None. + raw_completion (ChatCompletion, optional): ????, OpenAI ??. Defaults to None. """ if tools_call_args is None: @@ -443,7 +443,7 @@ def completion_text(self, value) -> None: comp for comp in self.result_chain.chain if not isinstance(comp, Comp.Plain) - ] # 清空 Plain 组件 + ] # ?? Plain ?? self.result_chain.chain.insert(0, Comp.Plain(value)) else: self._completion_text = value @@ -452,18 +452,29 @@ def to_openai_tool_calls(self) -> list[dict]: """Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): + if idx >= len(self.tools_call_name): + break + tool_name = self.tools_call_name[idx] + if not isinstance(tool_name, str) or not tool_name.strip(): + logger.warning("Skipping malformed tool call with empty tool name.") + continue + tool_call_id = ( + self.tools_call_ids[idx] + if idx < len(self.tools_call_ids) + and isinstance(self.tools_call_ids[idx], str) + and self.tools_call_ids[idx].strip() + else f"call_{uuid.uuid4().hex}" + ) payload = { - "id": self.tools_call_ids[idx], + "id": tool_call_id, "function": { - "name": self.tools_call_name[idx], + "name": tool_name.strip(), "arguments": json.dumps(tool_call_arg), }, "type": "function", } - if self.tools_call_extra_content.get(self.tools_call_ids[idx]): - payload["extra_content"] = self.tools_call_extra_content[ - self.tools_call_ids[idx] - ] + if self.tools_call_extra_content.get(tool_call_id): + payload["extra_content"] = self.tools_call_extra_content[tool_call_id] ret.append(payload) return ret @@ -471,16 +482,29 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: """The same as to_openai_tool_calls but return pydantic model.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): + if idx >= len(self.tools_call_name): + break + tool_name = self.tools_call_name[idx] + if not isinstance(tool_name, str) or not tool_name.strip(): + logger.warning("Skipping malformed tool call with empty tool name.") + continue + tool_call_id = ( + self.tools_call_ids[idx] + if idx < len(self.tools_call_ids) + and isinstance(self.tools_call_ids[idx], str) + and self.tools_call_ids[idx].strip() + else f"call_{uuid.uuid4().hex}" + ) ret.append( ToolCall( - id=self.tools_call_ids[idx], + id=tool_call_id, function=ToolCall.FunctionBody( - name=self.tools_call_name[idx], + name=tool_name.strip(), arguments=json.dumps(tool_call_arg), ), # the extra_content will not serialize if it's None when calling ToolCall.model_dump() extra_content=self.tools_call_extra_content.get( - self.tools_call_ids[idx] + tool_call_id ), ), ) @@ -490,6 +514,6 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: @dataclass class RerankResult: index: int - """在候选列表中的索引位置""" + """???????????""" relevance_score: float - """相关性分数""" + """?????""" diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 8aa2778f1b..c14cfdd876 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -52,12 +52,12 @@ @register_provider_adapter( "openai_chat_completion", - "OpenAI API Chat Completion 提供商适配器", + "OpenAI API Chat Completion ??????", ) class ProviderOpenAIOfficial(Provider): _ERROR_TEXT_CANDIDATE_MAX_CHARS = 4096 - # 部分 OpenAI 兼容中转站会校验 data URL 的 MIME 类型是否和图片字节一致。 - # 这里统一维护格式映射,确保本地文件和 `base64://` 图片引用使用相同声明。 + # ?? OpenAI ???????? data URL ? MIME ???????????? + # ?????????????????? `base64://` ??????????? _IMAGE_FORMAT_MIME_TYPES = { "JPEG": "image/jpeg", "PNG": "image/png", @@ -219,10 +219,10 @@ def _encode_image_file_to_data_url( @classmethod def _detect_image_format(cls, image_bytes: bytes) -> str | None: - """返回 Pillow 校验后的图片格式,非法图片返回 None。""" + """?? Pillow ??????????????? None?""" try: - # verify() 只校验图片容器,不完整解码像素。 - # 这里仅需要可信的格式标签,因此这种方式足够且开销较小。 + # verify() ???????????????? + # ??????????????????????????? with PILImage.open(BytesIO(image_bytes)) as image: image.verify() return str(image.format or "").upper() @@ -231,25 +231,25 @@ def _detect_image_format(cls, image_bytes: bytes) -> str | None: @classmethod def _image_format_to_mime_type(cls, image_format: str | None) -> str: - """将 Pillow 图片格式映射为 data URL 使用的 MIME 类型。""" - # 未识别格式保持历史 JPEG 兜底,兼容传入任意 `base64://` 内容的旧调用方。 + """? Pillow ??????? data URL ??? MIME ???""" + # ????????? JPEG ????????? `base64://` ???????? return cls._IMAGE_FORMAT_MIME_TYPES.get( str(image_format or "").upper(), "image/jpeg" ) @classmethod def _base64_image_ref_to_data_url(cls, image_ref: str) -> str: - """将 `base64://` 图片引用转换为带真实 MIME 的 data URL。""" + """? `base64://` ?????????? MIME ? data URL?""" raw_base64 = image_ref.removeprefix("base64://") mime_type = "image/jpeg" try: - # 平台适配器可能通过 `base64://` 传入 PNG/GIF/WebP 等图片字节, - # 但不会额外携带 MIME 元数据。发送 OpenAI 请求前先识别真实格式, - # 避免把 PNG 等图片错误声明为 JPEG。 + # ????????? `base64://` ?? PNG/GIF/WebP ?????? + # ??????? MIME ?????? OpenAI ??????????? + # ??? PNG ???????? JPEG? image_bytes = base64.b64decode(raw_base64) except (binascii.Error, ValueError): - # 对错误或非图片 base64 保持旧行为:继续返回 JPEG data URL, - # 避免让历史调用方因为格式识别失败而直接抛异常。 + # ??????? base64 ?????????? JPEG data URL? + # ??????????????????????? pass else: image_format = cls._detect_image_format(image_bytes) @@ -309,7 +309,7 @@ async def _resolve_image_part( else: image_data = await self._image_ref_to_data_url(image_url, mode="safe") if not image_data: - logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") + logger.warning(f"?? {image_url} ????????????") return None image_payload = {"url": image_data} @@ -326,12 +326,12 @@ def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]: image_url_data = part.get("image_url") if not isinstance(image_url_data, dict): - logger.warning("图片内容块格式无效,将保留原始内容。") + logger.warning("??????????????????") return None, None url = image_url_data.get("url") if not isinstance(url, str) or not url: - logger.warning("图片内容块缺少有效 URL,将保留原始内容。") + logger.warning("????????? URL?????????") return None, None image_detail = image_url_data.get("detail") @@ -345,12 +345,12 @@ def _extract_audio_part_info(self, part: dict) -> str | None: audio_url_data = part.get("audio_url") if not isinstance(audio_url_data, dict): - logger.warning("音频内容块格式无效,将保留原始内容。") + logger.warning("??????????????????") return None url = audio_url_data.get("url") if not isinstance(url, str) or not url: - logger.warning("音频内容块缺少有效路径,将保留原始内容。") + logger.warning("????????????????????") return None return url @@ -384,7 +384,7 @@ async def _resolve_audio_part(self, audio_ref: str) -> dict | None: audio_format = "wav" audio_bytes = Path(audio_path).read_bytes() except Exception as exc: - logger.warning("音频 %s 预处理失败,将忽略。错误: %s", audio_ref, exc) + logger.warning("?? %s ????????????: %s", audio_ref, exc) return None finally: for cleanup_path in cleanup_paths: @@ -420,7 +420,7 @@ async def _transform_content_part(self, part: dict) -> dict: ) except Exception as exc: logger.warning( - "图片 %s 预处理失败,将保留原始内容。错误: %s", + "?? %s ????????????????: %s", url, exc, ) @@ -465,7 +465,7 @@ async def _fallback_to_text_only_and_retry( image_fallback_used: bool = False, ) -> tuple: logger.warning( - "检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。", + "??????????%s???????????????????", reason, ) new_contexts = await self._remove_image_from_context(context_query) @@ -481,7 +481,7 @@ async def _fallback_to_text_only_and_retry( ) def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: - """创建带代理的 HTTP 客户端""" + """?????? HTTP ???""" proxy = provider_config.get("proxy", "") httpx_module: Any = httpx try: @@ -566,16 +566,16 @@ async def get_models(self): models_str.append(model.id) return models_str except NotFoundError as e: - raise Exception(f"获取模型列表失败:{e}") + raise Exception(f"?????????{e}") @staticmethod def _sanitize_assistant_messages(payloads: dict) -> None: - """在请求发送前过滤/规范化空的 assistant 消息。 + """????????/????? assistant ??? - 严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少 - ``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]`` - 都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content - 设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。 + ?? API?Moonshot?DeepSeek Reasoner ???? assistant ?????? + ``content`` ? ``tool_calls`` ??? 400?? ``""`` / ``None`` / ``[]`` + ???????? tool_calls ???????? tool_calls ?? content + ?? ``None`` ??? OpenAI ??????? ``payloads["messages"]``? """ messages = payloads.get("messages") if not isinstance(messages, list): @@ -595,7 +595,7 @@ def _is_empty(content: Any) -> bool: reasoning_content = msg.get("reasoning_content") if _is_empty(content) and not tool_calls and not reasoning_content: - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + logger.warning(f"??? {idx} ?? assistant ?? (?????)") continue if _is_empty(content) and tool_calls: @@ -616,7 +616,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: payloads["tools"] = tool_list payloads["tool_choice"] = payloads.get("tool_choice", "auto") - # 不在默认参数中的参数放在 extra_body 中 + # ???????????? extra_body ? extra_body = {} to_del = [] for key in payloads: @@ -626,7 +626,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: for key in to_del: del payloads[key] - # 读取并合并 custom_extra_body 配置 + # ????? custom_extra_body ?? custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) @@ -642,9 +642,22 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: extra_body=extra_body, ) + if isinstance(completion, str): + text = self._normalize_content(completion) + if text: + logger.warning( + "OpenAI-compatible provider returned raw string completion; normalized it." + ) + return LLMResponse("assistant", completion_text=text) + raise EmptyModelOutputError( + "OpenAI-compatible provider returned an empty string completion." + ) + if isinstance(completion, dict): + completion = ChatCompletion.model_validate(completion) + if not isinstance(completion, ChatCompletion): raise Exception( - f"API 返回的 completion 类型错误:{type(completion)}: {completion}。", + f"API ??? completion ?????{type(completion)}: {completion}?", ) logger.debug(f"completion: {completion}") @@ -658,7 +671,7 @@ async def _query_stream( payloads: dict, tools: ToolSet | None, ) -> AsyncGenerator[LLMResponse, None]: - """流式查询API,逐步返回结果""" + """????API???????""" if tools: model = payloads.get("model", "").lower() omit_empty_param_field = "gemini" in model @@ -669,10 +682,10 @@ async def _query_stream( payloads["tools"] = tool_list payloads["tool_choice"] = payloads.get("tool_choice", "auto") - # 不在默认参数中的参数放在 extra_body 中 + # ???????????? extra_body ? extra_body = {} - # 读取并合并 custom_extra_body 配置 + # ????? custom_extra_body ?? custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) @@ -712,16 +725,16 @@ async def _query_stream( # Gemini and some OpenAI-compatible proxies omit this field if not hasattr(tc, "index") or tc.index is None: tc.index = idx - # 跳过 delta=None 的 chunk,避免 SDK 内部 _convert_initial_chunk_into_snapshot - # 第 747 行 choice.delta.to_dict() 抛出 NoneType 错误。 + # ?? delta=None ? chunk??? SDK ?? _convert_initial_chunk_into_snapshot + # ? 747 ? choice.delta.to_dict() ?? NoneType ??? # refs: AstrBot#6689 / openai-python#5069 / #5047 - # 例外:流末尾的 usage chunk(choices=[],delta=None 但有 usage 数据) - # 需要传给 state,否则最终 completion 会丢失 usage 信息 + # ??????? usage chunk?choices=[]?delta=None ?? usage ??? + # ???? state????? completion ??? usage ?? if delta is not None or chunk.usage: try: state.handle_chunk(chunk) except Exception as e: - logger.error("Saving chunk state error: " + str(e)) + logger.warning("Saving chunk state skipped: " + str(e)) # logger.debug(f"chunk delta: {delta}") # handle the content delta reasoning = self._extract_reasoning_content(chunk) @@ -755,7 +768,7 @@ async def _query_stream( yield llm_response except Exception as e: logger.error("get_final_completion error: " + str(e)) - # 流式内容已通过 yield 发出,记录错误后正常结束即可 + # ??????? yield ?????????????? return def _extract_reasoning_content( @@ -789,8 +802,8 @@ def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage: cached = getattr(ptd, "cached_tokens", 0) if ptd else 0 cached = ( cached if isinstance(cached, int) else 0 - ) # ptd.cached_tokens 可能为None - prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # 安全 + ) # ptd.cached_tokens ???None + prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # ?? completion_tokens = getattr(usage, "completion_tokens", 0) or 0 cached = cached or 0 prompt_tokens = prompt_tokens or 0 @@ -940,31 +953,54 @@ async def _parse_openai_completion( # workaround for #1359 tool_call = json.loads(tool_call) if tools is None: - # 工具集未提供 + # ?????? # Should be unreachable - raise Exception("工具集未提供") + raise Exception("??????") if tool_call.type == "function": + func_name = getattr(tool_call.function, "name", None) + if not isinstance(func_name, str) or not func_name.strip(): + logger.warning( + "Skipping malformed tool call with empty function name: %s", + tool_call, + ) + continue + func_name = func_name.strip() + tool_call_id = getattr(tool_call, "id", None) + if not isinstance(tool_call_id, str) or not tool_call_id.strip(): + tool_call_id = f"call_{uuid.uuid4().hex}" + logger.warning( + "Generated missing tool_call id for %s: %s", + func_name, + tool_call_id, + ) # workaround for #1454 if isinstance(tool_call.function.arguments, str): try: args = json.loads(tool_call.function.arguments) except json.JSONDecodeError as e: - logger.error(f"解析参数失败: {e}") + logger.error(f"??????: {e}") args = {} else: args = tool_call.function.arguments # Some API may return None for tools with no parameters if args is None: args = {} + if not isinstance(args, dict): + logger.warning( + "Tool call arguments for %s are not an object: %s", + func_name, + type(args).__name__, + ) + args = {} args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) + func_name_ls.append(func_name) + tool_call_ids.append(tool_call_id) # gemini-2.5 / gemini-3 series extra_content handling extra_content = getattr(tool_call, "extra_content", None) if extra_content is not None: - tool_call_extra_content_dict[tool_call.id] = extra_content + tool_call_extra_content_dict[tool_call_id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls @@ -974,7 +1010,7 @@ async def _parse_openai_completion( # specially handle finish reason if choice.finish_reason == "content_filter": raise Exception( - "API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。", + "API ??? completion ???????????(? AstrBot)?", ) has_text_output = bool((llm_response.completion_text or "").strip()) has_reasoning_output = bool((llm_response.reasoning_content or "").strip()) @@ -1009,7 +1045,7 @@ async def _prepare_chat_payload( extra_user_content_parts: list[ContentPart] | None = None, **kwargs, ) -> tuple: - """准备聊天所需的有效载荷和上下文""" + """???????????????""" if contexts is None: contexts = [] new_record = None @@ -1058,8 +1094,8 @@ def _finally_convert_payload(self, payloads: dict) -> None: model in deepseek_reasoning_models or "api.deepseek.com" in self.client.base_url.host ) - # MiMo 推理模型(MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash) - # 要求 assistant 历史消息必须回传 reasoning_content,否则返回 400 + # MiMo ?????MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash? + # ?? assistant ???????? reasoning_content????? 400 mimo_reasoning_models = { "mimo-v2.5-pro", "mimo-v2.5", @@ -1101,12 +1137,12 @@ def _finally_convert_payload(self, payloads: dict) -> None: and is_mimo_reasoning and "reasoning_content" not in message ): - # MiMo 推理模型要求 assistant 历史消息回传 reasoning_content, - # 缺失时 API 返回 400。参见 MiMo 官方文档。 + # MiMo ?????? assistant ?????? reasoning_content? + # ??? API ?? 400??? MiMo ????? message["reasoning_content"] = "" - # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), - # 纯文本会触发 400 Invalid argument,需要包一层 JSON。 + # Gemini ? function_response ?? google.protobuf.Struct?? JSON ???? + # ?????? 400 Invalid argument?????? JSON? if is_gemini and message.get("role") == "tool": content = message.get("content", "") if isinstance(content, str): @@ -1129,12 +1165,12 @@ async def _handle_api_error( max_retries: int, image_fallback_used: bool = False, ) -> tuple: - """处理API错误并尝试恢复""" + """??API???????""" if "429" in str(e): logger.warning( - f"API 调用过于频繁,尝试使用其他 Key 重试。当前 Key: {chosen_key[:12]}", + f"API ????????????? Key ????? Key: {chosen_key[:12]}", ) - # 最后一次不等待 + # ??????? if retry_cnt < max_retries - 1: await asyncio.sleep(1) if chosen_key in available_api_keys: @@ -1153,7 +1189,7 @@ async def _handle_api_error( raise e if "maximum context length" in str(e) or "context length" in str(e).lower(): logger.warning( - f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}", + f"??????????????????????????????: {len(context_query)}", ) await self.pop_record(context_query) payloads["messages"] = context_query @@ -1169,7 +1205,7 @@ async def _handle_api_error( if "The model is not a VLM" in str(e): # siliconcloud if image_fallback_used or not self._context_contains_image(context_query): raise e - # 尝试删除所有 image + # ?????? image return await self._fallback_to_text_only_and_retry( payloads, context_query, @@ -1209,9 +1245,9 @@ async def _handle_api_error( or ("tool" in str(e).lower() and "support" in str(e).lower()) or ("function" in str(e).lower() and "support" in str(e).lower()) ): - # openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配 + # openai, ollama, gemini openai, siliconcloud ?????? code ????????????? logger.warning( - f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。如需永久关闭,可前往 WebUI 中关闭工具调用。", + f"{self.get_model()} ???????????????????????????????? WebUI ????????", ) payloads.pop("tools", None) return ( @@ -1223,7 +1259,7 @@ async def _handle_api_error( None, image_fallback_used, ) - # logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}") + # logger.error(f"??????Provider ????: {self.provider_config}") if is_connection_error(e): proxy = self.provider_config.get("proxy", "") @@ -1298,9 +1334,9 @@ async def text_chat( break if retry_cnt == max_retries - 1 or llm_response is None: - logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") + logger.error(f"API ??????? {max_retries} ??????") if last_exception is None: - raise Exception("未知错误") + raise Exception("????") raise last_exception return llm_response @@ -1318,7 +1354,7 @@ async def text_chat_stream( tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> AsyncGenerator[LLMResponse, None]: - """流式对话,与服务商交互并逐步返回结果""" + """??????????????????""" payloads, context_query = await self._prepare_chat_payload( prompt, image_urls, @@ -1370,13 +1406,13 @@ async def text_chat_stream( break if retry_cnt == max_retries - 1: - logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") + logger.error(f"API ??????? {max_retries} ??????") if last_exception is None: - raise Exception("未知错误") + raise Exception("????") raise last_exception async def _remove_image_from_context(self, contexts: list): - """从上下文中删除所有带有 image 的记录""" + """??????????? image ???""" new_contexts = [] for context in contexts: @@ -1388,8 +1424,8 @@ async def _remove_image_from_context(self, contexts: list): continue new_content.append(item) if not new_content: - # 用户只发了图片 - new_content = [{"type": "text", "text": "[图片]"}] + # ??????? + new_content = [{"type": "text", "text": "[??]"}] context["content"] = new_content new_contexts.append(context) return new_contexts @@ -1410,24 +1446,24 @@ async def assemble_context( audio_urls: list[str] | None = None, extra_user_content_parts: list[ContentPart] | None = None, ) -> dict: - """组装成符合 OpenAI 格式的 role 为 user 的消息段""" + """????? OpenAI ??? role ? user ????""" - # 构建内容块列表 + # ??????? content_blocks = [] - # 1. 用户原始发言(OpenAI 建议:用户发言在前) + # 1. ???????OpenAI ?????????? if text: content_blocks.append({"type": "text", "text": text}) elif image_urls: - # 如果没有文本但有图片,添加占位文本 + # ????????????????? content_blocks.append({"type": "text", "text": "[Image]"}) elif audio_urls: content_blocks.append({"type": "text", "text": "[Audio]"}) elif extra_user_content_parts: - # 如果只有额外内容块,也需要添加占位文本 + # ??????????????????? content_blocks.append({"type": "text", "text": " "}) - # 2. 额外的内容块(系统提醒、指令等) + # 2. ???????????????? if extra_user_content_parts: for part in extra_user_content_parts: if isinstance(part, TextPart): @@ -1443,9 +1479,9 @@ async def assemble_context( if audio_part: content_blocks.append(audio_part) else: - raise ValueError(f"不支持的额外内容块类型: {type(part)}") + raise ValueError(f"???????????: {type(part)}") - # 3. 图片内容 + # 3. ???? if image_urls: for image_url in image_urls: image_part = await self._resolve_image_part(image_url) @@ -1458,7 +1494,7 @@ async def assemble_context( if audio_part: content_blocks.append(audio_part) - # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容 + # ???????????????????????????????? if ( text and not extra_user_content_parts @@ -1469,11 +1505,11 @@ async def assemble_context( ): return {"role": "user", "content": content_blocks[0]["text"]} - # 否则返回多模态格式 + # ????????? return {"role": "user", "content": content_blocks} async def encode_image_bs64(self, image_url: str) -> str: - """将图片转换为 base64""" + """?????? base64""" image_data = await self._image_ref_to_data_url(image_url, mode="strict") if image_data is None: raise RuntimeError(f"Failed to encode image data: {image_url}") diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index 969976a4fc..d95a502abb 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -260,7 +260,7 @@ def _iter_requirement_lines( resolved_path = os.path.realpath(requirements_path) if resolved_path in visited: logger.warning( - "检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path + "???????? requirements ??: %s???????", resolved_path ) return visited.add(resolved_path) @@ -311,7 +311,7 @@ def extract_requirement_names(requirements_path: str) -> set[str]: name for name, _ in iter_requirements(requirements_path=requirements_path) } except Exception as exc: - logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc) + logger.warning("???????????????: %s", exc) return set() @@ -325,9 +325,8 @@ def get_requirement_check_paths() -> list[str]: def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]: - distribution_name = ( - distribution.metadata["Name"] if "Name" in distribution.metadata else None - ) + metadata = distribution.metadata + distribution_name = metadata.get("Name") if metadata else None if not distribution_name: return None, None return canonicalize_distribution_name(distribution_name), distribution.version @@ -337,12 +336,18 @@ def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] installed: dict[str, str] = {} try: for distribution in importlib_metadata.distributions(path=paths): - distribution_name, version = _canonical_distribution_identity(distribution) + try: + distribution_name, version = _canonical_distribution_identity( + distribution + ) + except Exception as exc: + logger.debug("Skipping unreadable distribution metadata: %s", exc) + continue if not distribution_name or not version: continue installed.setdefault(distribution_name, version) except Exception as exc: - logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc) + logger.warning("???????????????????: %s", exc) return None return installed @@ -354,7 +359,7 @@ def _load_requirement_lines_for_precheck( requirement_lines = list(_iter_requirement_lines(requirements_path)) except Exception as exc: logger.warning( - "预检查缺失依赖失败,将回退到完整安装: %s (%s)", + "??????????????????: %s (%s)", requirements_path, exc, ) @@ -379,7 +384,7 @@ def _load_requirement_lines_for_precheck( ) if fallback_line is not None: logger.info( - "缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)", + "???????????????? option/direct-reference ??????????: %s (%s)", requirements_path, fallback_line, ) @@ -448,7 +453,7 @@ def build_missing_requirements_install_lines( if parsed is None: if looks_like_direct_reference(line) or line.startswith(("-", "--")): logger.debug( - "缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)", + "???????????????requirements ?????????? option/direct-reference ?: %s (%s)", requirements_path, line, ) @@ -486,7 +491,7 @@ def plan_missing_requirements_install( return None if missing and not install_lines: logger.warning( - "预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s", + "??????????????????? requirement ??????????: %s -> %s", requirements_path, sorted(missing), ) @@ -507,5 +512,5 @@ def plan_missing_requirements_install( def find_missing_requirements_or_raise(requirements_path: str) -> set[str]: missing = find_missing_requirements(requirements_path) if missing is None: - raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}") + raise RequirementsPrecheckFailed(f"?????: {requirements_path}") return missing From 670c99ad62e79bc913833de0c4dd829433a0eec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:31:34 +0800 Subject: [PATCH 02/12] Apply Ruff formatting --- astrbot/core/provider/entities.py | 90 +++++++++++++++---------------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 00f681a324..5bfeebceb9 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -67,12 +67,12 @@ class ProviderMetaData(ProviderMeta): @dataclass class ToolCallsResult: - """??????""" + """工具调用结果""" tool_calls_info: AssistantMessageSegment - """???????""" + """函数调用的信息""" tool_calls_result: list[ToolCallMessageSegment] - """???????""" + """函数调用的结果""" def to_openai_messages(self) -> list[dict]: ret = [ @@ -93,30 +93,30 @@ def to_openai_messages_model( @dataclass class ProviderRequest: prompt: str | None = None - """???""" + """提示词""" session_id: str | None = "" - """?? ID""" + """会话 ID""" image_urls: list[str] = field(default_factory=list) - """?? URL ??""" + """图片 URL 列表""" audio_urls: list[str] = field(default_factory=list) - """?? URL ??????????""" + """音频 URL 列表,也支持本地路径""" extra_user_content_parts: list[ContentPart] = field(default_factory=list) - """???????????????????????????????????????????? dict ? ContentPart ??""" + """额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象""" func_tool: ToolSet | None = None - """???????""" + """可用的函数工具""" contexts: list[dict] = field(default_factory=list) """ - OpenAI ???????? - ?? https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages + OpenAI 格式上下文列表。 + 参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages """ system_prompt: str = "" - """?????""" + """系统提示词""" conversation: Conversation | None = None - """???????""" + """关联的对话对象""" tool_calls_result: list[ToolCallsResult] | ToolCallsResult | None = None - """??????????????????: https://platform.openai.com/docs/guides/function-calling#handling-function-calls""" + """附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls""" model: str | None = None - """?????? None ???????????""" + """模型名称,为 None 时使用提供商的默认模型""" def __repr__(self) -> str: return ( @@ -133,7 +133,7 @@ def __str__(self) -> str: return self.__repr__() def append_tool_calls_result(self, tool_calls_result: ToolCallsResult) -> None: - """????????????""" + """添加工具调用结果到请求中""" if not self.tool_calls_result: self.tool_calls_result = [] if isinstance(self.tool_calls_result, ToolCallsResult): @@ -141,7 +141,7 @@ def append_tool_calls_result(self, tool_calls_result: ToolCallsResult) -> None: self.tool_calls_result.append(tool_calls_result) def _print_friendly_context(self): - """?????????????????????????""" + """打印友好的消息上下文。将多模态内容折叠为简短标记。""" if not self.contexts: return ( f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}, " @@ -189,26 +189,26 @@ def _print_friendly_context(self): return "\n".join(result_parts) async def assemble_context(self) -> dict: - """???(prompt?image_urls ? audio_urls)??????????""" - # ??????? + """将请求(prompt、image_urls 和 audio_urls)包装成统一消息格式。""" + # 构建内容块列表 content_blocks = [] - # 1. ???????OpenAI ?????????? + # 1. 用户原始发言(OpenAI 建议:用户发言在前) if self.prompt and self.prompt.strip(): content_blocks.append({"type": "text", "text": self.prompt}) elif self.image_urls: - # ????????????????? - content_blocks.append({"type": "text", "text": "[??]"}) + # 如果没有文本但有图片,添加占位文本 + content_blocks.append({"type": "text", "text": "[图片]"}) elif self.audio_urls: - # ????????????????? - content_blocks.append({"type": "text", "text": "[??]"}) + # 如果没有文本但有音频,添加占位文本 + content_blocks.append({"type": "text", "text": "[音频]"}) - # 2. ???????????????? + # 2. 额外的内容块(系统提醒、指令等) if self.extra_user_content_parts: for part in self.extra_user_content_parts: content_blocks.append(part.model_dump_for_context()) - # 3. ???? + # 3. 图片内容 if self.image_urls: for image_url in self.image_urls: if image_url.startswith("http"): @@ -220,13 +220,13 @@ async def assemble_context(self) -> dict: else: image_data = await self._encode_image_bs64(image_url) if not image_data: - logger.warning(f"?? {image_url} ????????????") + logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") continue content_blocks.append( {"type": "image_url", "image_url": {"url": image_data}}, ) - # 4. ???? + # 4. 音频内容 if self.audio_urls: for audio_url in self.audio_urls: if audio_url.startswith("http"): @@ -264,13 +264,13 @@ async def assemble_context(self) -> dict: source_ref=audio_url, ) if not audio_data: - logger.warning(f"?? {audio_url} ????????????") + logger.warning(f"音频 {audio_url} 得到的结果为空,将忽略。") continue content_blocks.append( {"type": "audio_url", "audio_url": {"url": audio_data}}, ) - # ????????? prompt ????????????????????????????? + # 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容 if ( len(content_blocks) == 1 and content_blocks[0]["type"] == "text" @@ -280,11 +280,11 @@ async def assemble_context(self) -> dict: ): return {"role": "user", "content": content_blocks[0]["text"]} - # ????????? + # 否则返回多模态格式 return {"role": "user", "content": content_blocks} async def _encode_image_bs64(self, image_url: str) -> str: - """?????? base64""" + """将图片转换为 base64""" if image_url.startswith("base64://"): return image_url.replace("base64://", "data:image/jpeg;base64,") with open(image_url, "rb") as f: @@ -296,7 +296,7 @@ async def _encode_audio_bs64( audio_path: str, source_ref: str | None = None, ) -> str: - """?????? base64""" + """将音频转换为 base64""" mime_type = "audio/wav" if audio_path.startswith("base64://"): @@ -393,15 +393,15 @@ def __init__( id: str | None = None, usage: TokenUsage | None = None, ) -> None: - """??? LLMResponse + """初始化 LLMResponse Args: - role (str): ??, assistant, tool, err - completion_text (str, optional): ????????????????? result_chain. Defaults to "". - result_chain (MessageChain, optional): ??????. Defaults to None. - tools_call_args (List[Dict[str, any]], optional): ??????. Defaults to None. - tools_call_name (List[str], optional): ??????. Defaults to None. - raw_completion (ChatCompletion, optional): ????, OpenAI ??. Defaults to None. + role (str): 角色, assistant, tool, err + completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "". + result_chain (MessageChain, optional): 返回的消息链. Defaults to None. + tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None. + tools_call_name (List[str], optional): 工具调用名称. Defaults to None. + raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None. """ if tools_call_args is None: @@ -443,7 +443,7 @@ def completion_text(self, value) -> None: comp for comp in self.result_chain.chain if not isinstance(comp, Comp.Plain) - ] # ?? Plain ?? + ] # 清空 Plain 组件 self.result_chain.chain.insert(0, Comp.Plain(value)) else: self._completion_text = value @@ -503,9 +503,7 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: arguments=json.dumps(tool_call_arg), ), # the extra_content will not serialize if it's None when calling ToolCall.model_dump() - extra_content=self.tools_call_extra_content.get( - tool_call_id - ), + extra_content=self.tools_call_extra_content.get(tool_call_id), ), ) return ret @@ -514,6 +512,6 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: @dataclass class RerankResult: index: int - """???????????""" + """在候选列表中的索引位置""" relevance_score: float - """?????""" + """相关性分数""" From 83f5ae6332f983beb266b713cc955cd48185afef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:37:23 +0800 Subject: [PATCH 03/12] Apply Ruff formatting to astrbot/core/provider/sources/openai_source.py --- .../core/provider/sources/openai_source.py | 186 +++++++++--------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index c14cfdd876..8ee14852f6 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -13,18 +13,8 @@ from typing import Any, Literal from urllib.parse import unquote, urlparse -import httpx -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai._exceptions import NotFoundError -from openai.lib.streaming.chat._completions import ChatCompletionStreamState -from openai.types.chat.chat_completion import ChatCompletion -from openai.types.chat.chat_completion_chunk import ChatCompletionChunk -from openai.types.completion_usage import CompletionUsage -from PIL import Image as PILImage -from PIL import UnidentifiedImageError - import astrbot.core.message.components as Comp -from astrbot import logger +import httpx from astrbot.api.provider import Provider from astrbot.core.agent.message import ( AudioURLPart, @@ -36,7 +26,6 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url from astrbot.core.utils.media_utils import ensure_wav @@ -46,18 +35,29 @@ log_connection_failure, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings +from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai._exceptions import NotFoundError +from openai.lib.streaming.chat._completions import ChatCompletionStreamState +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.completion_usage import CompletionUsage +from PIL import Image as PILImage +from PIL import UnidentifiedImageError + +from astrbot import logger +from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from ..register import register_provider_adapter @register_provider_adapter( "openai_chat_completion", - "OpenAI API Chat Completion ??????", + "OpenAI API Chat Completion 提供商适配器", ) class ProviderOpenAIOfficial(Provider): _ERROR_TEXT_CANDIDATE_MAX_CHARS = 4096 - # ?? OpenAI ???????? data URL ? MIME ???????????? - # ?????????????????? `base64://` ??????????? + # 部分 OpenAI 兼容中转站会校验 data URL 的 MIME 类型是否和图片字节一致。 + # 这里统一维护格式映射,确保本地文件和 `base64://` 图片引用使用相同声明。 _IMAGE_FORMAT_MIME_TYPES = { "JPEG": "image/jpeg", "PNG": "image/png", @@ -219,10 +219,10 @@ def _encode_image_file_to_data_url( @classmethod def _detect_image_format(cls, image_bytes: bytes) -> str | None: - """?? Pillow ??????????????? None?""" + """返回 Pillow 校验后的图片格式,非法图片返回 None。""" try: - # verify() ???????????????? - # ??????????????????????????? + # verify() 只校验图片容器,不完整解码像素。 + # 这里仅需要可信的格式标签,因此这种方式足够且开销较小。 with PILImage.open(BytesIO(image_bytes)) as image: image.verify() return str(image.format or "").upper() @@ -231,25 +231,25 @@ def _detect_image_format(cls, image_bytes: bytes) -> str | None: @classmethod def _image_format_to_mime_type(cls, image_format: str | None) -> str: - """? Pillow ??????? data URL ??? MIME ???""" - # ????????? JPEG ????????? `base64://` ???????? + """将 Pillow 图片格式映射为 data URL 使用的 MIME 类型。""" + # 未识别格式保持历史 JPEG 兜底,兼容传入任意 `base64://` 内容的旧调用方。 return cls._IMAGE_FORMAT_MIME_TYPES.get( str(image_format or "").upper(), "image/jpeg" ) @classmethod def _base64_image_ref_to_data_url(cls, image_ref: str) -> str: - """? `base64://` ?????????? MIME ? data URL?""" + """将 `base64://` 图片引用转换为带真实 MIME 的 data URL。""" raw_base64 = image_ref.removeprefix("base64://") mime_type = "image/jpeg" try: - # ????????? `base64://` ?? PNG/GIF/WebP ?????? - # ??????? MIME ?????? OpenAI ??????????? - # ??? PNG ???????? JPEG? + # 平台适配器可能通过 `base64://` 传入 PNG/GIF/WebP 等图片字节, + # 但不会额外携带 MIME 元数据。发送 OpenAI 请求前先识别真实格式, + # 避免把 PNG 等图片错误声明为 JPEG。 image_bytes = base64.b64decode(raw_base64) except (binascii.Error, ValueError): - # ??????? base64 ?????????? JPEG data URL? - # ??????????????????????? + # 对错误或非图片 base64 保持旧行为:继续返回 JPEG data URL, + # 避免让历史调用方因为格式识别失败而直接抛异常。 pass else: image_format = cls._detect_image_format(image_bytes) @@ -309,7 +309,7 @@ async def _resolve_image_part( else: image_data = await self._image_ref_to_data_url(image_url, mode="safe") if not image_data: - logger.warning(f"?? {image_url} ????????????") + logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") return None image_payload = {"url": image_data} @@ -326,12 +326,12 @@ def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]: image_url_data = part.get("image_url") if not isinstance(image_url_data, dict): - logger.warning("??????????????????") + logger.warning("图片内容块格式无效,将保留原始内容。") return None, None url = image_url_data.get("url") if not isinstance(url, str) or not url: - logger.warning("????????? URL?????????") + logger.warning("图片内容块缺少有效 URL,将保留原始内容。") return None, None image_detail = image_url_data.get("detail") @@ -345,12 +345,12 @@ def _extract_audio_part_info(self, part: dict) -> str | None: audio_url_data = part.get("audio_url") if not isinstance(audio_url_data, dict): - logger.warning("??????????????????") + logger.warning("音频内容块格式无效,将保留原始内容。") return None url = audio_url_data.get("url") if not isinstance(url, str) or not url: - logger.warning("????????????????????") + logger.warning("音频内容块缺少有效路径,将保留原始内容。") return None return url @@ -384,7 +384,7 @@ async def _resolve_audio_part(self, audio_ref: str) -> dict | None: audio_format = "wav" audio_bytes = Path(audio_path).read_bytes() except Exception as exc: - logger.warning("?? %s ????????????: %s", audio_ref, exc) + logger.warning("音频 %s 预处理失败,将忽略。错误: %s", audio_ref, exc) return None finally: for cleanup_path in cleanup_paths: @@ -420,7 +420,7 @@ async def _transform_content_part(self, part: dict) -> dict: ) except Exception as exc: logger.warning( - "?? %s ????????????????: %s", + "图片 %s 预处理失败,将保留原始内容。错误: %s", url, exc, ) @@ -465,7 +465,7 @@ async def _fallback_to_text_only_and_retry( image_fallback_used: bool = False, ) -> tuple: logger.warning( - "??????????%s???????????????????", + "检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。", reason, ) new_contexts = await self._remove_image_from_context(context_query) @@ -481,7 +481,7 @@ async def _fallback_to_text_only_and_retry( ) def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: - """?????? HTTP ???""" + """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") httpx_module: Any = httpx try: @@ -566,16 +566,16 @@ async def get_models(self): models_str.append(model.id) return models_str except NotFoundError as e: - raise Exception(f"?????????{e}") + raise Exception(f"获取模型列表失败:{e}") @staticmethod def _sanitize_assistant_messages(payloads: dict) -> None: - """????????/????? assistant ??? + """在请求发送前过滤/规范化空的 assistant 消息。 - ?? API?Moonshot?DeepSeek Reasoner ???? assistant ?????? - ``content`` ? ``tool_calls`` ??? 400?? ``""`` / ``None`` / ``[]`` - ???????? tool_calls ???????? tool_calls ?? content - ?? ``None`` ??? OpenAI ??????? ``payloads["messages"]``? + 严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少 + ``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]`` + 都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content + 设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。 """ messages = payloads.get("messages") if not isinstance(messages, list): @@ -595,7 +595,7 @@ def _is_empty(content: Any) -> bool: reasoning_content = msg.get("reasoning_content") if _is_empty(content) and not tool_calls and not reasoning_content: - logger.warning(f"??? {idx} ?? assistant ?? (?????)") + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") continue if _is_empty(content) and tool_calls: @@ -616,7 +616,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: payloads["tools"] = tool_list payloads["tool_choice"] = payloads.get("tool_choice", "auto") - # ???????????? extra_body ? + # 不在默认参数中的参数放在 extra_body 中 extra_body = {} to_del = [] for key in payloads: @@ -626,7 +626,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: for key in to_del: del payloads[key] - # ????? custom_extra_body ?? + # 读取并合并 custom_extra_body 配置 custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) @@ -657,7 +657,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if not isinstance(completion, ChatCompletion): raise Exception( - f"API ??? completion ?????{type(completion)}: {completion}?", + f"API 返回的 completion 类型错误:{type(completion)}: {completion}。", ) logger.debug(f"completion: {completion}") @@ -671,7 +671,7 @@ async def _query_stream( payloads: dict, tools: ToolSet | None, ) -> AsyncGenerator[LLMResponse, None]: - """????API???????""" + """流式查询API,逐步返回结果""" if tools: model = payloads.get("model", "").lower() omit_empty_param_field = "gemini" in model @@ -682,10 +682,10 @@ async def _query_stream( payloads["tools"] = tool_list payloads["tool_choice"] = payloads.get("tool_choice", "auto") - # ???????????? extra_body ? + # 不在默认参数中的参数放在 extra_body 中 extra_body = {} - # ????? custom_extra_body ?? + # 读取并合并 custom_extra_body 配置 custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) @@ -725,11 +725,11 @@ async def _query_stream( # Gemini and some OpenAI-compatible proxies omit this field if not hasattr(tc, "index") or tc.index is None: tc.index = idx - # ?? delta=None ? chunk??? SDK ?? _convert_initial_chunk_into_snapshot - # ? 747 ? choice.delta.to_dict() ?? NoneType ??? + # 跳过 delta=None 的 chunk,避免 SDK 内部 _convert_initial_chunk_into_snapshot + # 第 747 行 choice.delta.to_dict() 抛出 NoneType 错误。 # refs: AstrBot#6689 / openai-python#5069 / #5047 - # ??????? usage chunk?choices=[]?delta=None ?? usage ??? - # ???? state????? completion ??? usage ?? + # 例外:流末尾的 usage chunk(choices=[],delta=None 但有 usage 数据) + # 需要传给 state,否则最终 completion 会丢失 usage 信息 if delta is not None or chunk.usage: try: state.handle_chunk(chunk) @@ -768,7 +768,7 @@ async def _query_stream( yield llm_response except Exception as e: logger.error("get_final_completion error: " + str(e)) - # ??????? yield ?????????????? + # 流式内容已通过 yield 发出,记录错误后正常结束即可 return def _extract_reasoning_content( @@ -802,8 +802,8 @@ def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage: cached = getattr(ptd, "cached_tokens", 0) if ptd else 0 cached = ( cached if isinstance(cached, int) else 0 - ) # ptd.cached_tokens ???None - prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # ?? + ) # ptd.cached_tokens 可能为None + prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # 安全 completion_tokens = getattr(usage, "completion_tokens", 0) or 0 cached = cached or 0 prompt_tokens = prompt_tokens or 0 @@ -953,9 +953,9 @@ async def _parse_openai_completion( # workaround for #1359 tool_call = json.loads(tool_call) if tools is None: - # ?????? + # 工具集未提供 # Should be unreachable - raise Exception("??????") + raise Exception("工具集未提供") if tool_call.type == "function": func_name = getattr(tool_call.function, "name", None) @@ -979,7 +979,7 @@ async def _parse_openai_completion( try: args = json.loads(tool_call.function.arguments) except json.JSONDecodeError as e: - logger.error(f"??????: {e}") + logger.error(f"解析参数失败: {e}") args = {} else: args = tool_call.function.arguments @@ -1010,7 +1010,7 @@ async def _parse_openai_completion( # specially handle finish reason if choice.finish_reason == "content_filter": raise Exception( - "API ??? completion ???????????(? AstrBot)?", + "API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。", ) has_text_output = bool((llm_response.completion_text or "").strip()) has_reasoning_output = bool((llm_response.reasoning_content or "").strip()) @@ -1045,7 +1045,7 @@ async def _prepare_chat_payload( extra_user_content_parts: list[ContentPart] | None = None, **kwargs, ) -> tuple: - """???????????????""" + """准备聊天所需的有效载荷和上下文""" if contexts is None: contexts = [] new_record = None @@ -1094,8 +1094,8 @@ def _finally_convert_payload(self, payloads: dict) -> None: model in deepseek_reasoning_models or "api.deepseek.com" in self.client.base_url.host ) - # MiMo ?????MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash? - # ?? assistant ???????? reasoning_content????? 400 + # MiMo 推理模型(MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash) + # 要求 assistant 历史消息必须回传 reasoning_content,否则返回 400 mimo_reasoning_models = { "mimo-v2.5-pro", "mimo-v2.5", @@ -1137,12 +1137,12 @@ def _finally_convert_payload(self, payloads: dict) -> None: and is_mimo_reasoning and "reasoning_content" not in message ): - # MiMo ?????? assistant ?????? reasoning_content? - # ??? API ?? 400??? MiMo ????? + # MiMo 推理模型要求 assistant 历史消息回传 reasoning_content, + # 缺失时 API 返回 400。参见 MiMo 官方文档。 message["reasoning_content"] = "" - # Gemini ? function_response ?? google.protobuf.Struct?? JSON ???? - # ?????? 400 Invalid argument?????? JSON? + # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), + # 纯文本会触发 400 Invalid argument,需要包一层 JSON。 if is_gemini and message.get("role") == "tool": content = message.get("content", "") if isinstance(content, str): @@ -1165,12 +1165,12 @@ async def _handle_api_error( max_retries: int, image_fallback_used: bool = False, ) -> tuple: - """??API???????""" + """处理API错误并尝试恢复""" if "429" in str(e): logger.warning( - f"API ????????????? Key ????? Key: {chosen_key[:12]}", + f"API 调用过于频繁,尝试使用其他 Key 重试。当前 Key: {chosen_key[:12]}", ) - # ??????? + # 最后一次不等待 if retry_cnt < max_retries - 1: await asyncio.sleep(1) if chosen_key in available_api_keys: @@ -1189,7 +1189,7 @@ async def _handle_api_error( raise e if "maximum context length" in str(e) or "context length" in str(e).lower(): logger.warning( - f"??????????????????????????????: {len(context_query)}", + f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}", ) await self.pop_record(context_query) payloads["messages"] = context_query @@ -1205,7 +1205,7 @@ async def _handle_api_error( if "The model is not a VLM" in str(e): # siliconcloud if image_fallback_used or not self._context_contains_image(context_query): raise e - # ?????? image + # 尝试删除所有 image return await self._fallback_to_text_only_and_retry( payloads, context_query, @@ -1245,9 +1245,9 @@ async def _handle_api_error( or ("tool" in str(e).lower() and "support" in str(e).lower()) or ("function" in str(e).lower() and "support" in str(e).lower()) ): - # openai, ollama, gemini openai, siliconcloud ?????? code ????????????? + # openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配 logger.warning( - f"{self.get_model()} ???????????????????????????????? WebUI ????????", + f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。如需永久关闭,可前往 WebUI 中关闭工具调用。", ) payloads.pop("tools", None) return ( @@ -1259,7 +1259,7 @@ async def _handle_api_error( None, image_fallback_used, ) - # logger.error(f"??????Provider ????: {self.provider_config}") + # logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}") if is_connection_error(e): proxy = self.provider_config.get("proxy", "") @@ -1334,9 +1334,9 @@ async def text_chat( break if retry_cnt == max_retries - 1 or llm_response is None: - logger.error(f"API ??????? {max_retries} ??????") + logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") if last_exception is None: - raise Exception("????") + raise Exception("未知错误") raise last_exception return llm_response @@ -1354,7 +1354,7 @@ async def text_chat_stream( tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> AsyncGenerator[LLMResponse, None]: - """??????????????????""" + """流式对话,与服务商交互并逐步返回结果""" payloads, context_query = await self._prepare_chat_payload( prompt, image_urls, @@ -1406,13 +1406,13 @@ async def text_chat_stream( break if retry_cnt == max_retries - 1: - logger.error(f"API ??????? {max_retries} ??????") + logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") if last_exception is None: - raise Exception("????") + raise Exception("未知错误") raise last_exception async def _remove_image_from_context(self, contexts: list): - """??????????? image ???""" + """从上下文中删除所有带有 image 的记录""" new_contexts = [] for context in contexts: @@ -1424,8 +1424,8 @@ async def _remove_image_from_context(self, contexts: list): continue new_content.append(item) if not new_content: - # ??????? - new_content = [{"type": "text", "text": "[??]"}] + # 用户只发了图片 + new_content = [{"type": "text", "text": "[图片]"}] context["content"] = new_content new_contexts.append(context) return new_contexts @@ -1446,24 +1446,24 @@ async def assemble_context( audio_urls: list[str] | None = None, extra_user_content_parts: list[ContentPart] | None = None, ) -> dict: - """????? OpenAI ??? role ? user ????""" + """组装成符合 OpenAI 格式的 role 为 user 的消息段""" - # ??????? + # 构建内容块列表 content_blocks = [] - # 1. ???????OpenAI ?????????? + # 1. 用户原始发言(OpenAI 建议:用户发言在前) if text: content_blocks.append({"type": "text", "text": text}) elif image_urls: - # ????????????????? + # 如果没有文本但有图片,添加占位文本 content_blocks.append({"type": "text", "text": "[Image]"}) elif audio_urls: content_blocks.append({"type": "text", "text": "[Audio]"}) elif extra_user_content_parts: - # ??????????????????? + # 如果只有额外内容块,也需要添加占位文本 content_blocks.append({"type": "text", "text": " "}) - # 2. ???????????????? + # 2. 额外的内容块(系统提醒、指令等) if extra_user_content_parts: for part in extra_user_content_parts: if isinstance(part, TextPart): @@ -1479,9 +1479,9 @@ async def assemble_context( if audio_part: content_blocks.append(audio_part) else: - raise ValueError(f"???????????: {type(part)}") + raise ValueError(f"不支持的额外内容块类型: {type(part)}") - # 3. ???? + # 3. 图片内容 if image_urls: for image_url in image_urls: image_part = await self._resolve_image_part(image_url) @@ -1494,7 +1494,7 @@ async def assemble_context( if audio_part: content_blocks.append(audio_part) - # ???????????????????????????????? + # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容 if ( text and not extra_user_content_parts @@ -1505,11 +1505,11 @@ async def assemble_context( ): return {"role": "user", "content": content_blocks[0]["text"]} - # ????????? + # 否则返回多模态格式 return {"role": "user", "content": content_blocks} async def encode_image_bs64(self, image_url: str) -> str: - """?????? base64""" + """将图片转换为 base64""" image_data = await self._image_ref_to_data_url(image_url, mode="strict") if image_data is None: raise RuntimeError(f"Failed to encode image data: {image_url}") From 14901b4a402685f2afd2e46697cba3d60d72e222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:37:27 +0800 Subject: [PATCH 04/12] Apply Ruff formatting to astrbot/core/utils/requirements_utils.py --- astrbot/core/utils/requirements_utils.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index d95a502abb..b56657266a 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -7,13 +7,12 @@ from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass +from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import SpecifierSet from packaging.version import InvalidVersion, Version -from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path -from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime - logger = logging.getLogger("astrbot") @@ -260,7 +259,7 @@ def _iter_requirement_lines( resolved_path = os.path.realpath(requirements_path) if resolved_path in visited: logger.warning( - "???????? requirements ??: %s???????", resolved_path + "检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path ) return visited.add(resolved_path) @@ -311,7 +310,7 @@ def extract_requirement_names(requirements_path: str) -> set[str]: name for name, _ in iter_requirements(requirements_path=requirements_path) } except Exception as exc: - logger.warning("???????????????: %s", exc) + logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc) return set() @@ -347,7 +346,7 @@ def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] continue installed.setdefault(distribution_name, version) except Exception as exc: - logger.warning("???????????????????: %s", exc) + logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc) return None return installed @@ -359,7 +358,7 @@ def _load_requirement_lines_for_precheck( requirement_lines = list(_iter_requirement_lines(requirements_path)) except Exception as exc: logger.warning( - "??????????????????: %s (%s)", + "预检查缺失依赖失败,将回退到完整安装: %s (%s)", requirements_path, exc, ) @@ -384,7 +383,7 @@ def _load_requirement_lines_for_precheck( ) if fallback_line is not None: logger.info( - "???????????????? option/direct-reference ??????????: %s (%s)", + "缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)", requirements_path, fallback_line, ) @@ -453,7 +452,7 @@ def build_missing_requirements_install_lines( if parsed is None: if looks_like_direct_reference(line) or line.startswith(("-", "--")): logger.debug( - "???????????????requirements ?????????? option/direct-reference ?: %s (%s)", + "缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)", requirements_path, line, ) @@ -491,7 +490,7 @@ def plan_missing_requirements_install( return None if missing and not install_lines: logger.warning( - "??????????????????? requirement ??????????: %s -> %s", + "预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s", requirements_path, sorted(missing), ) @@ -512,5 +511,5 @@ def plan_missing_requirements_install( def find_missing_requirements_or_raise(requirements_path: str) -> set[str]: missing = find_missing_requirements(requirements_path) if missing is None: - raise RequirementsPrecheckFailed(f"?????: {requirements_path}") + raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}") return missing From d24bdb85d6cb7ddbb38e8a47f901097d4c716dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:37:29 +0800 Subject: [PATCH 05/12] Apply Ruff formatting to astrbot/core/provider/entities.py --- astrbot/core/provider/entities.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 5bfeebceb9..8594833e68 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -9,12 +9,8 @@ from typing import Any from urllib.parse import urlparse -from anthropic.types import Message as AnthropicMessage -from google.genai.types import GenerateContentResponse -from openai.types.chat.chat_completion import ChatCompletion - import astrbot.core.message.components as Comp -from astrbot import logger +from anthropic.types import Message as AnthropicMessage from astrbot.core.agent.message import ( AssistantMessageSegment, ContentPart, @@ -27,6 +23,10 @@ from astrbot.core.message.message_event_result import MessageChain from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url +from google.genai.types import GenerateContentResponse +from openai.types.chat.chat_completion import ChatCompletion + +from astrbot import logger class ProviderType(enum.Enum): From d7c5aa1674d630ba041e4ef94e0da20e0c7e4515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:50:53 +0800 Subject: [PATCH 06/12] Fix Ruff import ordering for astrbot/core/provider/sources/openai_source.py --- .../core/provider/sources/openai_source.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 8ee14852f6..7b988e96dd 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -13,8 +13,18 @@ from typing import Any, Literal from urllib.parse import unquote, urlparse -import astrbot.core.message.components as Comp import httpx +from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai._exceptions import NotFoundError +from openai.lib.streaming.chat._completions import ChatCompletionStreamState +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.completion_usage import CompletionUsage +from PIL import Image as PILImage +from PIL import UnidentifiedImageError + +import astrbot.core.message.components as Comp +from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.agent.message import ( AudioURLPart, @@ -26,6 +36,7 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url from astrbot.core.utils.media_utils import ensure_wav @@ -35,17 +46,6 @@ log_connection_failure, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai._exceptions import NotFoundError -from openai.lib.streaming.chat._completions import ChatCompletionStreamState -from openai.types.chat.chat_completion import ChatCompletion -from openai.types.chat.chat_completion_chunk import ChatCompletionChunk -from openai.types.completion_usage import CompletionUsage -from PIL import Image as PILImage -from PIL import UnidentifiedImageError - -from astrbot import logger -from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from ..register import register_provider_adapter From 98784ad82c03911a1326a883a4e99c8cc70d59ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:50:59 +0800 Subject: [PATCH 07/12] Fix Ruff import ordering for astrbot/core/utils/requirements_utils.py --- astrbot/core/utils/requirements_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index b56657266a..d12c3e3348 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -7,12 +7,13 @@ from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass -from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path -from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import SpecifierSet from packaging.version import InvalidVersion, Version +from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime + logger = logging.getLogger("astrbot") From 7bce127c13eea53008ad955827ec9b3a5a6be5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 01:51:02 +0800 Subject: [PATCH 08/12] Fix Ruff import ordering for astrbot/core/provider/entities.py --- astrbot/core/provider/entities.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 8594833e68..5bfeebceb9 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -9,8 +9,12 @@ from typing import Any from urllib.parse import urlparse -import astrbot.core.message.components as Comp from anthropic.types import Message as AnthropicMessage +from google.genai.types import GenerateContentResponse +from openai.types.chat.chat_completion import ChatCompletion + +import astrbot.core.message.components as Comp +from astrbot import logger from astrbot.core.agent.message import ( AssistantMessageSegment, ContentPart, @@ -23,10 +27,6 @@ from astrbot.core.message.message_event_result import MessageChain from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url -from google.genai.types import GenerateContentResponse -from openai.types.chat.chat_completion import ChatCompletion - -from astrbot import logger class ProviderType(enum.Enum): From 3508bd231e74e789ac62ad285739285ae21ad68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 02:11:29 +0800 Subject: [PATCH 09/12] Address review feedback for astrbot/core/db/vec_db/faiss_impl/embedding_storage.py --- .../db/vec_db/faiss_impl/embedding_storage.py | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index 19163278f6..ed6547c349 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -2,7 +2,7 @@ import faiss except ModuleNotFoundError: raise ImportError( - "faiss ??????? 'pip install faiss-cpu' ? 'pip install faiss-gpu' ???", + "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", ) import os @@ -36,57 +36,60 @@ def _add_with_ids(self, vectors: np.ndarray, ids: np.ndarray) -> None: ) async def insert(self, vector: np.ndarray, id: int) -> None: - """???? + """插入向量 Args: - vector (np.ndarray): ?????? - id (int): ???ID + vector (np.ndarray): 要插入的向量 + id (int): 向量的ID Raises: - ValueError: ???????????????? + ValueError: 如果向量的维度与存储的维度不匹配 """ assert self.index is not None, "FAISS index is not initialized." if vector.shape[0] != self.dimension: raise ValueError( - f"???????, ??: {self.dimension}, ??: {vector.shape[0]}", + f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}", ) self._add_with_ids(vector.reshape(1, -1), np.array([id], dtype=np.int64)) await self.save_index() async def insert_batch(self, vectors: np.ndarray, ids: list[int]) -> None: - """?????? + """批量插入向量 Args: - vectors (np.ndarray): ???????? - ids (list[int]): ???ID?? + vectors (np.ndarray): 要插入的向量数组 + ids (list[int]): 向量的ID列表 Raises: - ValueError: ???????????????? + ValueError: 如果向量的维度与存储的维度不匹配 """ assert self.index is not None, "FAISS index is not initialized." if vectors.shape[1] != self.dimension: raise ValueError( - f"???????, ??: {self.dimension}, ??: {vectors.shape[1]}", + f"向量维度不匹配, 期望: {self.dimension}, 实际: {vectors.shape[1]}", ) self._add_with_ids(vectors, np.array(ids, dtype=np.int64)) await self.save_index() async def search(self, vector: np.ndarray, k: int) -> tuple: - """???????? + """搜索最相似的向量 Args: - vector (np.ndarray): ???? - k (int): ??????????? + vector (np.ndarray): 查询向量 + k (int): 返回的最相似向量的数量 Returns: - tuple: (??, ??) + tuple: (距离, 索引) """ assert self.index is not None, "FAISS index is not initialized." + vector = np.ascontiguousarray(vector, dtype=np.float32) + if vector.ndim == 1: + vector = vector.reshape(1, -1) faiss.normalize_L2(vector) try: distances, indices = self.index.search(vector, k) except TypeError as exc: - if "missing 3 required positional arguments" not in str(exc): + if "missing" not in str(exc): raise distances = np.empty((vector.shape[0], k), dtype=np.float32) indices = np.empty((vector.shape[0], k), dtype=np.int64) @@ -100,10 +103,10 @@ async def search(self, vector: np.ndarray, k: int) -> tuple: return distances, indices async def delete(self, ids: list[int]) -> None: - """???? + """删除向量 Args: - ids (list[int]): ??????ID?? + ids (list[int]): 要删除的向量ID列表 """ assert self.index is not None, "FAISS index is not initialized." @@ -112,10 +115,10 @@ async def delete(self, ids: list[int]) -> None: await self.save_index() async def save_index(self) -> None: - """???? + """保存索引 Args: - path (str): ??????? + path (str): 保存索引的路径 """ if self.index is None: From 9f72e98f2f4a8d2fa9d673892a4d539506966ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 02:11:32 +0800 Subject: [PATCH 10/12] Address review feedback for astrbot/core/provider/sources/openai_source.py --- .../core/provider/sources/openai_source.py | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 7b988e96dd..44a748df82 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -734,7 +734,10 @@ async def _query_stream( try: state.handle_chunk(chunk) except Exception as e: - logger.warning("Saving chunk state skipped: " + str(e)) + logger.warning( + f"Saving chunk state skipped for chunk {chunk!r}: {e}", + exc_info=True, + ) # logger.debug(f"chunk delta: {delta}") # handle the content delta reasoning = self._extract_reasoning_content(chunk) @@ -957,8 +960,23 @@ async def _parse_openai_completion( # Should be unreachable raise Exception("工具集未提供") - if tool_call.type == "function": - func_name = getattr(tool_call.function, "name", None) + is_dict_tool_call = isinstance(tool_call, dict) + tool_call_type = ( + tool_call.get("type") + if is_dict_tool_call + else getattr(tool_call, "type", None) + ) + if tool_call_type == "function": + tool_call_function = ( + tool_call.get("function") + if is_dict_tool_call + else getattr(tool_call, "function", None) + ) + func_name = ( + tool_call_function.get("name") + if isinstance(tool_call_function, dict) + else getattr(tool_call_function, "name", None) + ) if not isinstance(func_name, str) or not func_name.strip(): logger.warning( "Skipping malformed tool call with empty function name: %s", @@ -966,7 +984,11 @@ async def _parse_openai_completion( ) continue func_name = func_name.strip() - tool_call_id = getattr(tool_call, "id", None) + tool_call_id = ( + tool_call.get("id") + if is_dict_tool_call + else getattr(tool_call, "id", None) + ) if not isinstance(tool_call_id, str) or not tool_call_id.strip(): tool_call_id = f"call_{uuid.uuid4().hex}" logger.warning( @@ -975,14 +997,19 @@ async def _parse_openai_completion( tool_call_id, ) # workaround for #1454 - if isinstance(tool_call.function.arguments, str): + tool_call_arguments = ( + tool_call_function.get("arguments") + if isinstance(tool_call_function, dict) + else getattr(tool_call_function, "arguments", None) + ) + if isinstance(tool_call_arguments, str): try: - args = json.loads(tool_call.function.arguments) + args = json.loads(tool_call_arguments) except json.JSONDecodeError as e: - logger.error(f"解析参数失败: {e}") + logger.warning(f"解析参数失败: {e}") args = {} else: - args = tool_call.function.arguments + args = tool_call_arguments # Some API may return None for tools with no parameters if args is None: args = {} @@ -998,7 +1025,11 @@ async def _parse_openai_completion( tool_call_ids.append(tool_call_id) # gemini-2.5 / gemini-3 series extra_content handling - extra_content = getattr(tool_call, "extra_content", None) + extra_content = ( + tool_call.get("extra_content") + if is_dict_tool_call + else getattr(tool_call, "extra_content", None) + ) if extra_content is not None: tool_call_extra_content_dict[tool_call_id] = extra_content From c92a3f4f15cfddc9c44768fff9301c4189fd4a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Tue, 9 Jun 2026 02:11:35 +0800 Subject: [PATCH 11/12] Address review feedback for astrbot/core/provider/entities.py --- astrbot/core/provider/entities.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 5bfeebceb9..54157377dc 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -453,6 +453,10 @@ def to_openai_tool_calls(self) -> list[dict]: ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): if idx >= len(self.tools_call_name): + logger.warning( + "Skipping tool call argument without matching tool name at index %s.", + idx, + ) break tool_name = self.tools_call_name[idx] if not isinstance(tool_name, str) or not tool_name.strip(): @@ -483,6 +487,10 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): if idx >= len(self.tools_call_name): + logger.warning( + "Skipping tool call argument without matching tool name at index %s.", + idx, + ) break tool_name = self.tools_call_name[idx] if not isinstance(tool_name, str) or not tool_name.strip(): From 4b9eddd3a79dc9ae26c2db7e0f27c66a403f115f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A7=E6=BF=91=E7=BA=A2=E8=8E=89=E6=A0=96=EF=BC=88BOT?= =?UTF-8?q?=EF=BC=89?= <1051445024@qq.com> Date: Wed, 10 Jun 2026 12:47:29 +0800 Subject: [PATCH 12/12] fix(vec_db/faiss): tolerate SWIG remove_ids requiring IDSelector Mirror the existing add_with_ids/search SWIG-compat fallbacks for delete(). Some faiss builds compile IndexIDMap.remove_ids to accept only a faiss::IDSelector, so passing a raw int64 ndarray raises TypeError and breaks batch delete / memory cleanup. Fall back to IDSelectorBatch. --- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index ed6547c349..aadde10ac5 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -111,7 +111,13 @@ async def delete(self, ids: list[int]) -> None: """ assert self.index is not None, "FAISS index is not initialized." id_array = np.array(ids, dtype=np.int64) - self.index.remove_ids(id_array) + try: + self.index.remove_ids(id_array) + except TypeError as exc: + if "IDSelector" not in str(exc): + raise + selector = faiss.IDSelectorBatch(id_array.size, faiss.swig_ptr(id_array)) + self.index.remove_ids(selector) await self.save_index() async def save_index(self) -> None: