diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index cab53417..63b99b26 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -405,6 +405,7 @@ def _patch_openai_responses_instructions_support(): return _patch_openai_interleaved_reasoning_content_support() + _patch_openai_responses_empty_output_support() if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False): return @@ -464,6 +465,64 @@ def _patch_openai_responses_instructions_support(): logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性") +def _patch_openai_responses_empty_output_support(): + """ + 修补 langchain-openai Responses API 流式完成事件 output 为空的兼容性。 + + ChatGPT Codex 后端有时会在 `response.completed` chunk 里返回 + `response.output = None`,但前面的 delta chunk 已经包含实际文本。 + langchain-openai 在收尾阶段遍历 output 会抛出 TypeError,这里将缺失 + output 规整为空列表,让收尾 chunk 只承载 usage/metadata。 + """ + try: + import langchain_openai.chat_models.base as _openai_base + except Exception as err: + logger.debug(f"跳过 langchain-openai responses output 修补:{err}") + return + + if getattr(_openai_base, "_moviepilot_responses_empty_output_patched", False): + return + + original_construct = getattr( + _openai_base, "_construct_lc_result_from_responses_api", None + ) + if not callable(original_construct): + logger.warning("langchain-openai 缺少 Responses API 结果构造函数,无法修补 output") + return + + def _clone_response_with_empty_output(response): + """ + 复制 Responses 对象,把缺失 output 规整为空列表。 + """ + model_copy = getattr(response, "model_copy", None) + if callable(model_copy): + try: + return model_copy(update={"output": []}) + except Exception as err: + logger.debug(f"复制 Responses 对象失败,回退原地修补 output:{err}") + + try: + setattr(response, "output", []) + except Exception as err: + logger.debug(f"原地修补 Responses output 失败:{err}") + return response + + @wraps(original_construct) + def _patched_construct_lc_result_from_responses_api(response, *args, **kwargs): + """ + 在 Responses API 收尾 chunk 缺少 output 时跳过空内容遍历。 + """ + if hasattr(response, "output") and getattr(response, "output", None) is None: + response = _clone_response_with_empty_output(response) + return original_construct(response, *args, **kwargs) + + _openai_base._construct_lc_result_from_responses_api = ( + _patched_construct_lc_result_from_responses_api + ) + _openai_base._moviepilot_responses_empty_output_patched = True + logger.debug("已修补 langchain-openai responses API 空 output 兼容性") + + class LLMHelper: """LLM模型相关辅助功能""" diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index 0720ef2d..965c7f5d 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -108,8 +108,17 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch): def _convert_delta_to_message_chunk(delta, default_class): return AIMessageChunk(content=delta.get("content") or "") + def _construct_lc_result_from_responses_api(response, *args, **kwargs): + """模拟旧版 langchain-openai 直接遍历 response.output 的行为。""" + for _item in response.output: + pass + return SimpleNamespace(args=args, kwargs=kwargs, response=response) + base_module._convert_dict_to_message = _convert_dict_to_message base_module._convert_delta_to_message_chunk = _convert_delta_to_message_chunk + base_module._construct_lc_result_from_responses_api = ( + _construct_lc_result_from_responses_api + ) return { "langchain_openai": openai_module, @@ -262,6 +271,39 @@ class LlmHelperTestCallTest(unittest.TestCase): "先调用工具", ) + def test_openai_responses_patch_handles_completed_chunk_without_output(self): + """校验 Responses API 流式完成事件 output 为空时不再崩溃。""" + + class _FakeResponse: + """模拟 OpenAI Responses API 完成事件里的 Response 对象。""" + + def __init__(self, output): + """保存 output 字段用于复现空输出场景。""" + self.output = output + + def model_copy(self, update=None): + """模拟 Pydantic v2 model_copy(update=...) 行为。""" + copied = _FakeResponse(self.output) + for key, value in (update or {}).items(): + setattr(copied, key, value) + return copied + + fake_modules, openai_base = _build_fake_openai_modules() + with patch.dict(sys.modules, fake_modules): + with self.assertRaises(TypeError): + openai_base._construct_lc_result_from_responses_api( + _FakeResponse(None) + ) + + llm_module._patch_openai_responses_instructions_support() + result = openai_base._construct_lc_result_from_responses_api( + _FakeResponse(None), + schema=object, + ) + + self.assertEqual(result.response.output, []) + self.assertEqual(result.kwargs.get("schema"), object) + def test_openai_compatible_patch_injects_xiaomi_reasoning_content(self): fake_modules, _ = _build_fake_openai_modules() with patch.dict(sys.modules, fake_modules):