mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-25 17:53:52 +08:00
Release v2.1.3
修 issue #282 (DeepSeek 等非多模态供应商被 400 拒绝)。详见 CHANGELOG.md。
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -2,6 +2,17 @@
|
||||
|
||||
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [2.1.3] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- DeepSeek 等非多模态供应商被 400 拒绝(issue #282):`UniversalGPT.create_messages` 与 `_build_merge_messages` 此前**无条件**把 content 拼成 OpenAI 多模态数组 `[{"type":"text",...}]`,DeepSeek `deepseek-chat` 等模型不识别 `image_url` 变体直接报 `invalid_request_error`。`GPTFactory.from_config` 一律实例化 `UniversalGPT`,所以问题覆盖**所有**通过模型设置页接入的非多模态供应商,不止 DeepSeek。
|
||||
- 现按 `video_img_urls` 是否非空切换 content 形态:有图保留多模态数组(视觉模型不退化),无图退回 string。合并阶段历来不带图,统一改 string。
|
||||
- 与同包内 `deepseek_gpt.py` / `openai_gpt.py` / `qwen_gpt.py` 的 message builder 行为对齐。
|
||||
- 新增 `backend/tests/test_universal_gpt_content_format.py` 6 个 case 回归覆盖(含 `image_url` 字面 not-in JSON 断言)。
|
||||
|
||||
感谢 @voidborne-d 的修复(#345)。
|
||||
|
||||
## [2.1.2] - 2026-05-07
|
||||
|
||||
补 v2.1.1 上 ghcr.io 镜像构建失败的坑。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v2.1.2</h1>
|
||||
<h1 align="center" > BiliNote v2.1.3</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
@@ -53,6 +53,11 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
- 笔记顶部视频封面 Banner 展示
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
|
||||
### v2.1.3 修订
|
||||
|
||||
- 修复 DeepSeek 等非多模态供应商被 400 拒绝的问题(issue #282):`UniversalGPT` 的 message builder 按是否带图切换 string / 多模态数组形态
|
||||
- 感谢 @voidborne-d (#345)
|
||||
|
||||
### v2.1.2 修订
|
||||
|
||||
- 修复 v2.1.1 触发的 ghcr.io Docker 镜像构建失败(Node 18 + Tailwind v4 不兼容、缺 lockfile)
|
||||
|
||||
@@ -53,20 +53,26 @@ class UniversalGPT(GPT):
|
||||
extras=kwargs.get('extras'),
|
||||
)
|
||||
|
||||
# ⛳ 组装 content 数组,支持 text + image_url 混合
|
||||
content: List[dict] = [{"type": "text", "text": content_text}]
|
||||
video_img_urls = kwargs.get('video_img_urls', [])
|
||||
|
||||
for url in video_img_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "auto"
|
||||
}
|
||||
})
|
||||
content: list[dict] | str
|
||||
if video_img_urls:
|
||||
# 有截图时走 OpenAI 多模态 content 数组(text + image_url)
|
||||
content = [{"type": "text", "text": content_text}]
|
||||
for url in video_img_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "auto"
|
||||
}
|
||||
})
|
||||
else:
|
||||
# 纯文本场景退回 string content:DeepSeek deepseek-chat 等非多模态模型
|
||||
# 不识别 [{"type":"text",...}] 数组形态,会返回 invalid_request_error
|
||||
# (issue #282)。OpenAI 规范本身也允许 content 为 string。
|
||||
content = content_text
|
||||
|
||||
# 正确格式:整体包在一个 message 里,role + content array
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": content
|
||||
@@ -83,9 +89,10 @@ class UniversalGPT(GPT):
|
||||
|
||||
def _build_merge_messages(self, partials: list) -> list:
|
||||
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
|
||||
# 合并阶段没有图片,直接用 string content 兼容非多模态模型(issue #282)
|
||||
return [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": merge_text}]
|
||||
"content": merge_text
|
||||
}]
|
||||
|
||||
def _checkpoint_path(self, checkpoint_key: str) -> Path:
|
||||
|
||||
189
backend/tests/test_universal_gpt_content_format.py
Normal file
189
backend/tests/test_universal_gpt_content_format.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""issue #282 回归测试:UniversalGPT 拼装 content 时按是否有图片切换 string / array 形态。
|
||||
|
||||
DeepSeek deepseek-chat 等非多模态模型只接受 ``content`` 为字符串,旧实现无条件
|
||||
emit ``[{"type":"text","text":...}]`` 导致 ``invalid_request_error``。
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
def _install_stubs():
|
||||
app_mod = types.ModuleType("app")
|
||||
gpt_pkg = types.ModuleType("app.gpt")
|
||||
models_pkg = types.ModuleType("app.models")
|
||||
|
||||
base_mod = types.ModuleType("app.gpt.base")
|
||||
|
||||
class _GPT:
|
||||
pass
|
||||
|
||||
base_mod.GPT = _GPT
|
||||
|
||||
prompt_builder_mod = types.ModuleType("app.gpt.prompt_builder")
|
||||
|
||||
def _generate_base_prompt(**_kwargs):
|
||||
return "PROMPT_BODY"
|
||||
|
||||
prompt_builder_mod.generate_base_prompt = _generate_base_prompt
|
||||
|
||||
prompt_mod = types.ModuleType("app.gpt.prompt")
|
||||
prompt_mod.BASE_PROMPT = ""
|
||||
prompt_mod.AI_SUM = ""
|
||||
prompt_mod.SCREENSHOT = ""
|
||||
prompt_mod.LINK = ""
|
||||
prompt_mod.MERGE_PROMPT = "MERGE_HEAD"
|
||||
|
||||
utils_mod = types.ModuleType("app.gpt.utils")
|
||||
|
||||
def _fix_markdown(text):
|
||||
return text
|
||||
|
||||
utils_mod.fix_markdown = _fix_markdown
|
||||
|
||||
request_chunker_mod = types.ModuleType("app.gpt.request_chunker")
|
||||
|
||||
class _RequestChunker:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def group_texts_by_budget(self, texts, _builder, **_kwargs):
|
||||
return [texts]
|
||||
|
||||
request_chunker_mod.RequestChunker = _RequestChunker
|
||||
|
||||
gpt_model_mod = types.ModuleType("app.models.gpt_model")
|
||||
|
||||
class _GPTSource:
|
||||
pass
|
||||
|
||||
gpt_model_mod.GPTSource = _GPTSource
|
||||
|
||||
transcriber_model_mod = types.ModuleType("app.models.transcriber_model")
|
||||
|
||||
class _TranscriptSegment:
|
||||
def __init__(self, **kwargs):
|
||||
self.start = kwargs.get("start", 0)
|
||||
self.end = kwargs.get("end", 0)
|
||||
self.text = kwargs.get("text", "")
|
||||
|
||||
transcriber_model_mod.TranscriptSegment = _TranscriptSegment
|
||||
|
||||
sys.modules.setdefault("app", app_mod)
|
||||
sys.modules.setdefault("app.gpt", gpt_pkg)
|
||||
sys.modules.setdefault("app.models", models_pkg)
|
||||
sys.modules["app.gpt.base"] = base_mod
|
||||
sys.modules["app.gpt.prompt_builder"] = prompt_builder_mod
|
||||
sys.modules["app.gpt.prompt"] = prompt_mod
|
||||
sys.modules["app.gpt.utils"] = utils_mod
|
||||
sys.modules["app.gpt.request_chunker"] = request_chunker_mod
|
||||
sys.modules["app.models.gpt_model"] = gpt_model_mod
|
||||
sys.modules["app.models.transcriber_model"] = transcriber_model_mod
|
||||
|
||||
|
||||
def _load_universal_gpt_class():
|
||||
_install_stubs()
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
module_path = root / "app" / "gpt" / "universal_gpt.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"universal_gpt_content_format", module_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("universal_gpt module spec not found")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.UniversalGPT
|
||||
|
||||
|
||||
UniversalGPT = _load_universal_gpt_class()
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
"""create_messages 不会真的调用 client,给个空壳即可。"""
|
||||
|
||||
|
||||
def _make_gpt():
|
||||
return UniversalGPT(_DummyClient(), model="deepseek-chat")
|
||||
|
||||
|
||||
class TestCreateMessagesContentFormat(unittest.TestCase):
|
||||
"""覆盖 create_messages 在不同 video_img_urls 输入下的输出形态。"""
|
||||
|
||||
def test_no_images_emits_string_content(self):
|
||||
"""无图片时 content 为 str(DeepSeek / 非多模态模型可解析)。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[])
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
self.assertEqual(messages[0]["content"], "PROMPT_BODY")
|
||||
|
||||
def test_empty_image_list_emits_string_content(self):
|
||||
"""显式传入空列表也要走纯文本分支,避免图片字段误触发。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[], video_img_urls=[])
|
||||
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
|
||||
def test_with_images_emits_multimodal_array(self):
|
||||
"""有图片时保留多模态 array 形态,确保多模态模型功能不退化。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(
|
||||
segments=[],
|
||||
video_img_urls=["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
)
|
||||
|
||||
content = messages[0]["content"]
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertEqual(len(content), 3) # 1 text + 2 images
|
||||
self.assertEqual(content[0], {"type": "text", "text": "PROMPT_BODY"})
|
||||
self.assertEqual(content[1]["type"], "image_url")
|
||||
self.assertEqual(content[1]["image_url"]["url"], "https://example.com/a.jpg")
|
||||
self.assertEqual(content[1]["image_url"]["detail"], "auto")
|
||||
self.assertEqual(content[2]["image_url"]["url"], "https://example.com/b.jpg")
|
||||
|
||||
def test_no_image_url_field_when_no_images(self):
|
||||
"""纯文本响应里不应该出现 image_url 关键字 —— 这是触发 DeepSeek 400 的根因。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[])
|
||||
|
||||
import json
|
||||
serialized = json.dumps(messages, ensure_ascii=False)
|
||||
self.assertNotIn("image_url", serialized)
|
||||
|
||||
|
||||
class TestBuildMergeMessagesContentFormat(unittest.TestCase):
|
||||
"""合并阶段从不带图片,应该统一走 string content 路径。"""
|
||||
|
||||
def test_merge_messages_use_string_content(self):
|
||||
"""否则长视频 chunk 后的合并阶段还会复现 issue #282 错误。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt._build_merge_messages(["partial-A", "partial-B"])
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
self.assertIn("MERGE_HEAD", messages[0]["content"])
|
||||
self.assertIn("partial-A", messages[0]["content"])
|
||||
self.assertIn("partial-B", messages[0]["content"])
|
||||
|
||||
def test_merge_messages_no_image_url_field(self):
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt._build_merge_messages(["x"])
|
||||
|
||||
import json
|
||||
serialized = json.dumps(messages, ensure_ascii=False)
|
||||
self.assertNotIn("image_url", serialized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user