From 987878e4e649a50d8114bf3bb8c84e6598f38694 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Fri, 9 Jan 2026 19:04:02 +0800 Subject: [PATCH 01/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/provider/sources/openai_source.py | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 0708c09c72..5690341d90 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -259,18 +259,63 @@ async def _parse_openai_completion( # parse the text completion if choice.message.content is not None: - # text completion - completion_text = str(choice.message.content).strip() - # specially, some providers may set tags around reasoning content in the completion text, - # we use regex to remove them, and store then in reasoning_content field - reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) - matches = reasoning_pattern.findall(completion_text) - if matches: - llm_response.reasoning_content = "\n".join( - [match.strip() for match in matches], - ) - completion_text = reasoning_pattern.sub("", completion_text).strip() - llm_response.result_chain = MessageChain().message(completion_text) + # content can be either a plain string or a multimodal list + content = choice.message.content + # handle multimodal content returned as a list of parts + if isinstance(content, list): + reasoning_parts = [] + mc = MessageChain() + for part in content: + if not isinstance(part, dict): + # fallback: append as plain text + mc.message(str(part)) + continue + ptype = part.get("type") + if ptype == "text": + mc.message(part.get("text", "")) + elif ptype == "image_url": + image_field = part.get("image_url") + url = None + if isinstance(image_field, dict): + url = image_field.get("url") + else: + url = image_field + if url: + # data:image/...;base64,xxx + if isinstance(url, str) and "base64," in url: + base64_data = url.split("base64,", 1)[1] + mc.base64_image(base64_data) + elif isinstance(url, str) and url.startswith("base64://"): + mc.base64_image(url.replace("base64://", "")) + else: + mc.url_image(url) + elif ptype == "think": + # collect reasoning parts for later extraction + think_val = part.get("think") + if think_val: + reasoning_parts.append(str(think_val)) + else: + # unknown part type, append its textual representation + mc.message(json.dumps(part, ensure_ascii=False)) + + if reasoning_parts: + llm_response.reasoning_content = "\n".join( + [rp.strip() for rp in reasoning_parts] + ) + llm_response.result_chain = mc + else: + # text completion (string) + completion_text = str(content).strip() + # specially, some providers may set tags around reasoning content in the completion text, + # we use regex to remove them, and store then in reasoning_content field + reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) + matches = reasoning_pattern.findall(completion_text) + if matches: + llm_response.reasoning_content = "\n".join( + [match.strip() for match in matches], + ) + completion_text = reasoning_pattern.sub("", completion_text).strip() + llm_response.result_chain = MessageChain().message(completion_text) # parse the reasoning content if any # the priority is higher than the tag extraction From b5106165d6e4044af9c7aba46876a890d7bcfd8a Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Sat, 17 Jan 2026 23:44:35 +0800 Subject: [PATCH 02/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index a192025dc9..bd9ffdf47b 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -528,6 +528,13 @@ class Reply(BaseMessageComponent): def __init__(self, **_) -> None: super().__init__(**_) + async def to_dict(self) -> dict: + chain = self.chain if self.chain is not None else [] + return { + "type": "reply", + "data": {"id": self.id, "chain": [await comp.to_dict() for comp in chain]}, + } + class Poke(BaseMessageComponent): type: str = ComponentType.Poke @@ -630,12 +637,23 @@ async def to_dict(self) -> dict: class Json(BaseMessageComponent): type = ComponentType.Json data: dict + raw_data: str | None = None def __init__(self, data: str | dict, **_) -> None: if isinstance(data, str): - data = json.loads(data) + try: + self.raw_data = data + data = json.loads(data) + except json.JSONDecodeError: + data = {"raw": data} super().__init__(data=data, **_) + async def to_dict(self) -> dict: + return { + "type": "json", + "data": {"content": getattr(self, "raw_data", json.dumps(self.data))}, + } + class Unknown(BaseMessageComponent): type = ComponentType.Unknown From 9132ebb091a230bb95120a5f4412f6d39ca831f7 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 11:52:55 +0800 Subject: [PATCH 03/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取_parse_image_url_part 处理base64 --- .../core/provider/sources/openai_source.py | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 5690341d90..4b4cd37730 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -247,6 +247,31 @@ def _extract_usage(self, usage: CompletionUsage) -> TokenUsage: output=completion_tokens, ) + def _parse_image_url_part(self, image_field) -> str | None: + """解析 OpenAI image_url 部分并提取 URL + + Args: + image_field: 可以是字典或字符串格式的 image_url 字段 + + Returns: + 提取的 URL 或 base64 数据,如果无效则返回 None + """ + if isinstance(image_field, dict): + url = image_field.get("url") + else: + url = image_field + + if not url: + return None + + # 统一处理 base64 格式,提取纯 base64 数据 + if isinstance(url, str) and "base64," in url: + return url.split("base64,", 1)[1] + elif isinstance(url, str) and url.startswith("base64://"): + return url.replace("base64://", "") + else: + return url + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -275,20 +300,13 @@ async def _parse_openai_completion( mc.message(part.get("text", "")) elif ptype == "image_url": image_field = part.get("image_url") - url = None - if isinstance(image_field, dict): - url = image_field.get("url") - else: - url = image_field + url = self._parse_image_url_part(image_field) if url: - # data:image/...;base64,xxx - if isinstance(url, str) and "base64," in url: - base64_data = url.split("base64,", 1)[1] - mc.base64_image(base64_data) - elif isinstance(url, str) and url.startswith("base64://"): - mc.base64_image(url.replace("base64://", "")) - else: + # 判断是 base64 数据还是 URL + if url.startswith("http"): mc.url_image(url) + else: + mc.base64_image(url) elif ptype == "think": # collect reasoning parts for later extraction think_val = part.get("think") From 672dc1dc82e4036efa92edfe0bd3fb5efa231e8c Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 11:55:39 +0800 Subject: [PATCH 04/14] =?UTF-8?q?-=20=E5=BD=93=E5=8E=9F=E5=A7=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=98=AF=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=97=B6=EF=BC=88?= =?UTF-8?q?`raw=5Fdata`=20=E4=B8=8D=E4=B8=BA=20None=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20`{"type":=20"json",=20"data":=20{"content"?= =?UTF-8?q?:=20"..."}}`=20=E5=8C=85=E8=A3=85=E5=BD=A2=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当原始数据是字典时:直接返回 `{"type": "json", "data": {...原始字典...}}`,不使用 content 包装 --- astrbot/core/message/components.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index bd9ffdf47b..fd812cf101 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -649,9 +649,16 @@ def __init__(self, data: str | dict, **_) -> None: super().__init__(data=data, **_) async def to_dict(self) -> dict: + # 如果原始数据是字符串,使用 content 包装形式 + if self.raw_data is not None: + return { + "type": "json", + "data": {"content": self.raw_data}, + } + # 如果原始数据是字典,直接返回原始字典结构 return { "type": "json", - "data": {"content": getattr(self, "raw_data", json.dumps(self.data))}, + "data": self.data, } From 989c1f56a03220c26df7fe796d4af8906c4185f1 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 12:04:35 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E5=83=8F=E5=85=B6=E4=BB=96=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=80=E6=A0=B7=E4=BD=BF=E7=94=A8=20self.type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index fd812cf101..ce3ddfaf80 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -531,7 +531,7 @@ def __init__(self, **_) -> None: async def to_dict(self) -> dict: chain = self.chain if self.chain is not None else [] return { - "type": "reply", + "type": self.type.lower(), "data": {"id": self.id, "chain": [await comp.to_dict() for comp in chain]}, } From 3075337190c54a15ee0f721116d35bec80f052b6 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 20:21:25 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E5=9C=A8=20Json.=5F=5Finit=5F=5F=20?= =?UTF-8?q?=E4=B8=AD=20=E4=BC=A0=E5=85=A5=20self.raw=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index ce3ddfaf80..89a071de47 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -640,13 +640,14 @@ class Json(BaseMessageComponent): raw_data: str | None = None def __init__(self, data: str | dict, **_) -> None: + raw_data = None if isinstance(data, str): + raw_data = data try: - self.raw_data = data data = json.loads(data) except json.JSONDecodeError: data = {"raw": data} - super().__init__(data=data, **_) + super().__init__(data=data, raw_data=raw_data, **_) async def to_dict(self) -> dict: # 如果原始数据是字符串,使用 content 包装形式 From b24075fcf9ce287cafa46e78f61d250b11db92e9 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 20:24:31 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E5=9C=A8=20Json.to=5Fdict=20=E4=B8=AD=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20self.type.lower()=20=E6=9D=A5=E6=8E=A8?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 89a071de47..067c3c6771 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -653,12 +653,12 @@ async def to_dict(self) -> dict: # 如果原始数据是字符串,使用 content 包装形式 if self.raw_data is not None: return { - "type": "json", + "type": self.type.lower(), "data": {"content": self.raw_data}, } # 如果原始数据是字典,直接返回原始字典结构 return { - "type": "json", + "type": self.type.lower(), "data": self.data, } From 27ed75d88e92a4c13714182395842564d4d14d7b Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Fri, 9 Jan 2026 19:04:02 +0800 Subject: [PATCH 08/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/provider/sources/openai_source.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index adee24073d..559299b974 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -474,6 +474,7 @@ async def _parse_openai_completion( # parse the text completion if choice.message.content is not None: +<<<<<<< HEAD completion_text = self._normalize_content(choice.message.content) # specially, some providers may set tags around reasoning content in the completion text, # we use regex to remove them, and store then in reasoning_content field @@ -487,6 +488,65 @@ async def _parse_openai_completion( # Also clean up orphan tags that may leak from some models completion_text = re.sub(r"\s*$", "", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) +======= + # content can be either a plain string or a multimodal list + content = choice.message.content + # handle multimodal content returned as a list of parts + if isinstance(content, list): + reasoning_parts = [] + mc = MessageChain() + for part in content: + if not isinstance(part, dict): + # fallback: append as plain text + mc.message(str(part)) + continue + ptype = part.get("type") + if ptype == "text": + mc.message(part.get("text", "")) + elif ptype == "image_url": + image_field = part.get("image_url") + url = None + if isinstance(image_field, dict): + url = image_field.get("url") + else: + url = image_field + if url: + # data:image/...;base64,xxx + if isinstance(url, str) and "base64," in url: + base64_data = url.split("base64,", 1)[1] + mc.base64_image(base64_data) + elif isinstance(url, str) and url.startswith("base64://"): + mc.base64_image(url.replace("base64://", "")) + else: + mc.url_image(url) + elif ptype == "think": + # collect reasoning parts for later extraction + think_val = part.get("think") + if think_val: + reasoning_parts.append(str(think_val)) + else: + # unknown part type, append its textual representation + mc.message(json.dumps(part, ensure_ascii=False)) + + if reasoning_parts: + llm_response.reasoning_content = "\n".join( + [rp.strip() for rp in reasoning_parts] + ) + llm_response.result_chain = mc + else: + # text completion (string) + completion_text = str(content).strip() + # specially, some providers may set tags around reasoning content in the completion text, + # we use regex to remove them, and store then in reasoning_content field + reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) + matches = reasoning_pattern.findall(completion_text) + if matches: + llm_response.reasoning_content = "\n".join( + [match.strip() for match in matches], + ) + completion_text = reasoning_pattern.sub("", completion_text).strip() + llm_response.result_chain = MessageChain().message(completion_text) +>>>>>>> 987878e4 ([Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化) # parse the reasoning content if any # the priority is higher than the tag extraction From 5c893e0e697a747f38c7fdaafe1538afe220b132 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Sat, 17 Jan 2026 23:44:35 +0800 Subject: [PATCH 09/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 15265c38d1..7387ecdb0b 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -537,6 +537,13 @@ class Reply(BaseMessageComponent): def __init__(self, **_) -> None: super().__init__(**_) + async def to_dict(self) -> dict: + chain = self.chain if self.chain is not None else [] + return { + "type": "reply", + "data": {"id": self.id, "chain": [await comp.to_dict() for comp in chain]}, + } + class Poke(BaseMessageComponent): type: str = ComponentType.Poke @@ -639,12 +646,23 @@ async def to_dict(self) -> dict: class Json(BaseMessageComponent): type: ComponentType = ComponentType.Json data: dict + raw_data: str | None = None def __init__(self, data: str | dict, **_) -> None: if isinstance(data, str): - data = json.loads(data) + try: + self.raw_data = data + data = json.loads(data) + except json.JSONDecodeError: + data = {"raw": data} super().__init__(data=data, **_) + async def to_dict(self) -> dict: + return { + "type": "json", + "data": {"content": getattr(self, "raw_data", json.dumps(self.data))}, + } + class Unknown(BaseMessageComponent): type: ComponentType = ComponentType.Unknown From a953dac3c76b03d19bc490ebd785055e5ea47ecb Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 11:52:55 +0800 Subject: [PATCH 10/14] =?UTF-8?q?[Bug]=E5=BD=93=20LLM=20=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=8C=85=E5=90=AB=E7=B1=BB=E4=BC=BC?= =?UTF-8?q?=20JSON=20=E7=9A=84=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E6=B6=88=E6=81=AF=E7=9A=84=20content=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=AF=E8=83=BD=E8=A2=AB=E9=94=99=E8=AF=AF=E5=9C=B0?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E5=BA=8F=E5=88=97=E5=8C=96=20Fixes=20#4363?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取_parse_image_url_part 处理base64 --- .../core/provider/sources/openai_source.py | 223 +++++++++--------- 1 file changed, 107 insertions(+), 116 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 559299b974..a56bdf3ed7 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -133,94 +133,6 @@ def _context_contains_image(contexts: list[dict]) -> bool: return True return False - async def _fallback_to_text_only_and_retry( - self, - payloads: dict, - context_query: list, - chosen_key: str, - available_api_keys: list[str], - func_tool: ToolSet | None, - reason: str, - *, - image_fallback_used: bool = False, - ) -> tuple: - logger.warning( - "检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。", - reason, - ) - new_contexts = await self._remove_image_from_context(context_query) - payloads["messages"] = new_contexts - return ( - False, - chosen_key, - available_api_keys, - payloads, - new_contexts, - func_tool, - image_fallback_used, - ) - - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: - """创建带代理的 HTTP 客户端""" - proxy = provider_config.get("proxy", "") - return create_proxy_client("OpenAI", proxy) - - def __init__(self, provider_config, provider_settings) -> None: - super().__init__(provider_config, provider_settings) - self.chosen_api_key = None - self.api_keys: list = super().get_keys() - self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None - self.timeout = provider_config.get("timeout", 120) - self.custom_headers = provider_config.get("custom_headers", {}) - if isinstance(self.timeout, str): - self.timeout = int(self.timeout) - - if not isinstance(self.custom_headers, dict) or not self.custom_headers: - self.custom_headers = None - else: - for key in self.custom_headers: - self.custom_headers[key] = str(self.custom_headers[key]) - - if "api_version" in provider_config: - # Using Azure OpenAI API - self.client = AsyncAzureOpenAI( - api_key=self.chosen_api_key, - api_version=provider_config.get("api_version", None), - default_headers=self.custom_headers, - base_url=provider_config.get("api_base", ""), - timeout=self.timeout, - http_client=self._create_http_client(provider_config), - ) - else: - # Using OpenAI Official API - self.client = AsyncOpenAI( - api_key=self.chosen_api_key, - base_url=provider_config.get("api_base", None), - default_headers=self.custom_headers, - timeout=self.timeout, - http_client=self._create_http_client(provider_config), - ) - - self.default_params = inspect.signature( - self.client.chat.completions.create, - ).parameters.keys() - - model = provider_config.get("model", "unknown") - self.set_model(model) - - self.reasoning_key = "reasoning_content" - - async def get_models(self): - try: - models_str = [] - models = await self.client.models.list() - models = sorted(models.data, key=lambda x: x.id) - for model in models: - models_str.append(model.id) - return models_str - except NotFoundError as e: - raise Exception(f"获取模型列表失败:{e}") - async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: model = payloads.get("model", "").lower() @@ -372,6 +284,83 @@ def _extract_usage(self, usage: CompletionUsage) -> TokenUsage: output=completion_tokens, ) + async def _fallback_to_text_only_and_retry( + self, + payloads: dict, + context_query: list, + chosen_key: str, + available_api_keys: list[str], + func_tool: ToolSet | None, + reason: str, + *, + image_fallback_used: bool = False, + ) -> tuple: + logger.warning( + "检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。", + reason, + ) + new_contexts = await self._remove_image_from_context(context_query) + payloads["messages"] = new_contexts + return ( + False, + chosen_key, + available_api_keys, + payloads, + new_contexts, + func_tool, + image_fallback_used, + ) + + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + """创建带代理的 HTTP 客户端""" + proxy = provider_config.get("proxy", "") + return create_proxy_client("OpenAI", proxy) + + def __init__(self, provider_config, provider_settings) -> None: + super().__init__(provider_config, provider_settings) + self.chosen_api_key = None + self.api_keys: list = super().get_keys() + self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None + self.timeout = provider_config.get("timeout", 120) + self.custom_headers = provider_config.get("custom_headers", {}) + if isinstance(self.timeout, str): + self.timeout = int(self.timeout) + + if not isinstance(self.custom_headers, dict) or not self.custom_headers: + self.custom_headers = None + else: + for key in self.custom_headers: + self.custom_headers[key] = str(self.custom_headers[key]) + + if "api_version" in provider_config: + # Using Azure OpenAI API + self.client = AsyncAzureOpenAI( + api_key=self.chosen_api_key, + api_version=provider_config.get("api_version", None), + default_headers=self.custom_headers, + base_url=provider_config.get("api_base", ""), + timeout=self.timeout, + http_client=self._create_http_client(provider_config), + ) + else: + # Using OpenAI Official API + self.client = AsyncOpenAI( + api_key=self.chosen_api_key, + base_url=provider_config.get("api_base", None), + default_headers=self.custom_headers, + timeout=self.timeout, + http_client=self._create_http_client(provider_config), + ) + + self.default_params = inspect.signature( + self.client.chat.completions.create, + ).parameters.keys() + + model = provider_config.get("model", "unknown") + self.set_model(model) + + self.reasoning_key = "reasoning_content" + @staticmethod def _normalize_content(raw_content: Any, strip: bool = True) -> str: """Normalize content from various formats to plain string. @@ -462,6 +451,31 @@ def _normalize_content(raw_content: Any, strip: bool = True) -> str: # Fallback for other types (int, float, etc.) return str(raw_content) if raw_content is not None else "" + def _parse_image_url_part(self, image_field) -> str | None: + """解析 OpenAI image_url 部分并提取 URL + + Args: + image_field: 可以是字典或字符串格式的 image_url 字段 + + Returns: + 提取的 URL 或 base64 数据,如果无效则返回 None + """ + if isinstance(image_field, dict): + url = image_field.get("url") + else: + url = image_field + + if not url: + return None + + # 统一处理 base64 格式,提取纯 base64 数据 + if isinstance(url, str) and "base64," in url: + return url.split("base64,", 1)[1] + elif isinstance(url, str) and url.startswith("base64://"): + return url.replace("base64://", "") + else: + return url + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -474,21 +488,6 @@ async def _parse_openai_completion( # parse the text completion if choice.message.content is not None: -<<<<<<< HEAD - completion_text = self._normalize_content(choice.message.content) - # specially, some providers may set tags around reasoning content in the completion text, - # we use regex to remove them, and store then in reasoning_content field - reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) - matches = reasoning_pattern.findall(completion_text) - if matches: - llm_response.reasoning_content = "\n".join( - [match.strip() for match in matches], - ) - completion_text = reasoning_pattern.sub("", completion_text).strip() - # Also clean up orphan tags that may leak from some models - completion_text = re.sub(r"\s*$", "", completion_text).strip() - llm_response.result_chain = MessageChain().message(completion_text) -======= # content can be either a plain string or a multimodal list content = choice.message.content # handle multimodal content returned as a list of parts @@ -505,20 +504,13 @@ async def _parse_openai_completion( mc.message(part.get("text", "")) elif ptype == "image_url": image_field = part.get("image_url") - url = None - if isinstance(image_field, dict): - url = image_field.get("url") - else: - url = image_field + url = self._parse_image_url_part(image_field) if url: - # data:image/...;base64,xxx - if isinstance(url, str) and "base64," in url: - base64_data = url.split("base64,", 1)[1] - mc.base64_image(base64_data) - elif isinstance(url, str) and url.startswith("base64://"): - mc.base64_image(url.replace("base64://", "")) - else: + # 判断是 base64 数据还是 URL + if url.startswith("http"): mc.url_image(url) + else: + mc.base64_image(url) elif ptype == "think": # collect reasoning parts for later extraction think_val = part.get("think") @@ -546,7 +538,6 @@ async def _parse_openai_completion( ) completion_text = reasoning_pattern.sub("", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) ->>>>>>> 987878e4 ([Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化) # parse the reasoning content if any # the priority is higher than the tag extraction From c11a7df0016a3255c9986ed2c0ee05e3fc938922 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 11:55:39 +0800 Subject: [PATCH 11/14] =?UTF-8?q?-=20=E5=BD=93=E5=8E=9F=E5=A7=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=98=AF=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=97=B6=EF=BC=88?= =?UTF-8?q?`raw=5Fdata`=20=E4=B8=8D=E4=B8=BA=20None=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20`{"type":=20"json",=20"data":=20{"content"?= =?UTF-8?q?:=20"..."}}`=20=E5=8C=85=E8=A3=85=E5=BD=A2=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当原始数据是字典时:直接返回 `{"type": "json", "data": {...原始字典...}}`,不使用 content 包装 --- astrbot/core/message/components.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 7387ecdb0b..e8e0154cc8 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -658,9 +658,16 @@ def __init__(self, data: str | dict, **_) -> None: super().__init__(data=data, **_) async def to_dict(self) -> dict: + # 如果原始数据是字符串,使用 content 包装形式 + if self.raw_data is not None: + return { + "type": "json", + "data": {"content": self.raw_data}, + } + # 如果原始数据是字典,直接返回原始字典结构 return { "type": "json", - "data": {"content": getattr(self, "raw_data", json.dumps(self.data))}, + "data": self.data, } From c528c5f8cfe40d50aa696179a05e7c7ac9ec733f Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 12:04:35 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E5=83=8F=E5=85=B6=E4=BB=96=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=80=E6=A0=B7=E4=BD=BF=E7=94=A8=20self.type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index e8e0154cc8..7db4eb2fff 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -540,7 +540,7 @@ def __init__(self, **_) -> None: async def to_dict(self) -> dict: chain = self.chain if self.chain is not None else [] return { - "type": "reply", + "type": self.type.lower(), "data": {"id": self.id, "chain": [await comp.to_dict() for comp in chain]}, } From 22c117906f0f18a7d6db952a87a80ff4e24468b8 Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 20:21:25 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E5=9C=A8=20Json.=5F=5Finit=5F=5F=20?= =?UTF-8?q?=E4=B8=AD=20=E4=BC=A0=E5=85=A5=20self.raw=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 7db4eb2fff..eec8ac6068 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -649,13 +649,14 @@ class Json(BaseMessageComponent): raw_data: str | None = None def __init__(self, data: str | dict, **_) -> None: + raw_data = None if isinstance(data, str): + raw_data = data try: - self.raw_data = data data = json.loads(data) except json.JSONDecodeError: data = {"raw": data} - super().__init__(data=data, **_) + super().__init__(data=data, raw_data=raw_data, **_) async def to_dict(self) -> dict: # 如果原始数据是字符串,使用 content 包装形式 From 4d782b66a53522606af78bef5659dba3e36d39ed Mon Sep 17 00:00:00 2001 From: Sjshi763 Date: Thu, 29 Jan 2026 20:24:31 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E5=9C=A8=20Json.to=5Fdict=20=E4=B8=AD=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20self.type.lower()=20=E6=9D=A5=E6=8E=A8?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/message/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index eec8ac6068..12aa531221 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -662,12 +662,12 @@ async def to_dict(self) -> dict: # 如果原始数据是字符串,使用 content 包装形式 if self.raw_data is not None: return { - "type": "json", + "type": self.type.lower(), "data": {"content": self.raw_data}, } # 如果原始数据是字典,直接返回原始字典结构 return { - "type": "json", + "type": self.type.lower(), "data": self.data, }