Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,29 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
stream=False,
extra_body=extra_body,
)


# --- 新增:兼容某些 API 强制返回 SSE 格式的 Bug ---
if isinstance(completion, str):
logger.warning(f"检测到 API 返回了字符串而非对象,尝试自动修复: {completion[:100]}...")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): Logging the raw response snippet may expose sensitive content and could be toned down or guarded.

This substring can still include user prompts or other private data, which may be sensitive depending on where logs are stored or shipped. Please consider masking/redacting the payload, logging only metadata (e.g., length or content type), or gating this detailed snippet behind a debug-only flag.

Suggested implementation:

        if isinstance(completion, str):
            # 避免在日志中暴露原始响应内容,仅记录元信息
            logger.warning("检测到 API 返回了字符串而非对象,尝试自动修复。为保护隐私,已省略响应内容。")
            # 在 debug 级别可输出更详细的调试信息(例如长度),不包含具体文本
            try:
                response_length = len(completion)
            except Exception:
                response_length = None
            if logger.isEnabledFor(logging.DEBUG):
                logger.debug("原始字符串响应元信息: length=%s", response_length)
            try:

If not already present at the top of astrbot/core/provider/sources/openai_source.py, add import logging and ensure logger is configured appropriately for your project’s logging setup.

try:
# 如果是 data:{...} 格式,去掉 "data:" 并解析 JSON
json_str = completion.strip()
if json_str.startswith("data:"):
json_str = json_str[5:].strip()

# 尝试解析 JSON
completion_dict = json.loads(json_str)
Comment on lines +467 to +476
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): SSE-like data: responses can contain multiple lines and trailing markers which this logic currently ignores.

Some gateways send multi-line SSE chunks (e.g. data:{...}\n\ndata:[DONE]) or extra newlines. Since this code only strips a single leading data:, json.loads will fail when there are multiple data: lines or a [DONE] sentinel. Consider splitting on newlines, discarding [DONE]/empty lines, and parsing only the last valid data: JSON line.


# 重新构造 ChatCompletion 对象
completion = ChatCompletion.construct(**completion_dict)
logger.info("成功将字符串响应转换为 ChatCompletion 对象。")

Comment on lines +475 to +481
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using ChatCompletion.construct bypasses validation and may admit malformed data.

Because construct skips validation and type coercion, malformed or partially invalid JSON can become a ChatCompletion instance that violates its invariants and fails later in harder-to-debug ways. Prefer a validated constructor (e.g. ChatCompletion(**completion_dict) or a proper from_* helper) so bad responses fail fast with validation errors.

Suggested change
# 尝试解析 JSON
completion_dict = json.loads(json_str)
# 重新构造 ChatCompletion 对象
completion = ChatCompletion.construct(**completion_dict)
logger.info("成功将字符串响应转换为 ChatCompletion 对象。")
# 尝试解析 JSON
completion_dict = json.loads(json_str)
# 重新构造 ChatCompletion 对象(使用带验证的构造函数,而非 construct)
completion = ChatCompletion(**completion_dict)
logger.info("成功将字符串响应转换为 ChatCompletion 对象。")

except Exception as e:
logger.error(f"自动修复失败: {e}")
# 如果修复失败,继续抛出原始错误
raise Exception(f"API 返回格式错误且无法修复:{type(completion)}: {completion}。")
Comment on lines +482 to +485
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Re-raising a new generic Exception here loses the original traceback and error type.

The comment promises to rethrow the original error, but the code creates a new Exception, discarding the original stack trace and specific error from json.loads / ChatCompletion. To keep debugging context, either use a bare raise to rethrow e, or raise a more specific/custom exception with raise ... from e so the root cause is preserved via exception chaining.

Comment on lines +467 to +485
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

这里存在两个主要问题:

  1. 非递归构造问题ChatCompletion.construct(或 Pydantic v2 中的 model_construct)不是递归的。这意味着 completion_dict 中的嵌套字典(如 choices 列表中的项)不会被自动转换为 Pydantic 模型对象,而是保留为 dict。这会导致后续代码(如第 731 行)在访问 choice.message.content 时抛出 AttributeError。建议使用 ChatCompletion.model_validate()
  2. 多行 SSE 处理问题:如果 API 返回的是包含多行的 SSE 响应(例如末尾带有 data: [DONE]),当前的 json.loads 会因为包含非 JSON 字符而解析失败。建议通过 splitlines() 遍历并提取第一个有效的 JSON 数据块。

此外,建议优化异常处理逻辑以提高鲁棒性。

        if isinstance(completion, str):
            logger.warning(f"检测到 API 返回了字符串而非对象,尝试自动修复: {completion[:100]}...")
            try:
                # 兼容多行 SSE 格式,提取第一个包含有效 JSON 的 data 行
                json_str = None
                for line in completion.splitlines():
                    line = line.strip()
                    if line.startswith("data:"):
                        content = line[5:].strip()
                        if content and content != "[DONE]":
                            json_str = content
                            break
                
                if not json_str:
                    json_str = completion.strip()
                
                completion_dict = json.loads(json_str)
                
                # 使用 model_validate 以确保嵌套对象(如 choices, message)被正确解析为 Pydantic 模型
                # construct 方法不是递归的,会导致后续访问属性时抛出 AttributeError
                completion = ChatCompletion.model_validate(completion_dict)
                logger.info("成功将字符串响应转换为 ChatCompletion 对象。")
                
            except Exception as e:
                logger.error(f"自动修复失败: {e}")
                raise Exception(f"API 返回格式错误且无法修复:{type(completion)}: {completion}。")

# ---------------------------------------------------
Comment on lines +465 to +486
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# --- 新增:兼容某些 API 强制返回 SSE 格式的 Bug ---
if isinstance(completion, str):
logger.warning(f"检测到 API 返回了字符串而非对象,尝试自动修复: {completion[:100]}...")
try:
# 如果是 data:{...} 格式,去掉 "data:" 并解析 JSON
json_str = completion.strip()
if json_str.startswith("data:"):
json_str = json_str[5:].strip()
# 尝试解析 JSON
completion_dict = json.loads(json_str)
# 重新构造 ChatCompletion 对象
completion = ChatCompletion.construct(**completion_dict)
logger.info("成功将字符串响应转换为 ChatCompletion 对象。")
except Exception as e:
logger.error(f"自动修复失败: {e}")
# 如果修复失败,继续抛出原始错误
raise Exception(f"API 返回格式错误且无法修复:{type(completion)}: {completion}。")
# ---------------------------------------------------
if isinstance(completion, str):
try:
# see #7280
json_str = completion.strip().removeprefix("data:").strip()
completion_dict = json.loads(json_str)
completion = ChatCompletion.construct(**completion_dict)
except Exception as e:
raise Exception(
f"The API returned a string response that cannot be parsed as a ChatCompletion. Response: {completion[:200]}... Error: {e}"
)


if not isinstance(completion, ChatCompletion):
raise Exception(
f"API 返回的 completion 类型错误:{type(completion)}: {completion}。",
Expand Down