mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix: handle empty ChatGPT responses output
This commit is contained in:
@@ -405,6 +405,7 @@ def _patch_openai_responses_instructions_support():
|
|||||||
return
|
return
|
||||||
|
|
||||||
_patch_openai_interleaved_reasoning_content_support()
|
_patch_openai_interleaved_reasoning_content_support()
|
||||||
|
_patch_openai_responses_empty_output_support()
|
||||||
|
|
||||||
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False):
|
if getattr(ChatOpenAI, "_moviepilot_responses_instructions_patched", False):
|
||||||
return
|
return
|
||||||
@@ -464,6 +465,64 @@ def _patch_openai_responses_instructions_support():
|
|||||||
logger.debug("已修补 langchain-openai responses API 的 instructions 兼容性")
|
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:
|
class LLMHelper:
|
||||||
"""LLM模型相关辅助功能"""
|
"""LLM模型相关辅助功能"""
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,17 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
|
|||||||
def _convert_delta_to_message_chunk(delta, default_class):
|
def _convert_delta_to_message_chunk(delta, default_class):
|
||||||
return AIMessageChunk(content=delta.get("content") or "")
|
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_dict_to_message = _convert_dict_to_message
|
||||||
base_module._convert_delta_to_message_chunk = _convert_delta_to_message_chunk
|
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 {
|
return {
|
||||||
"langchain_openai": openai_module,
|
"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):
|
def test_openai_compatible_patch_injects_xiaomi_reasoning_content(self):
|
||||||
fake_modules, _ = _build_fake_openai_modules()
|
fake_modules, _ = _build_fake_openai_modules()
|
||||||
with patch.dict(sys.modules, fake_modules):
|
with patch.dict(sys.modules, fake_modules):
|
||||||
|
|||||||
Reference in New Issue
Block a user