Skip to content

fix(gemini): stop serializing empty parts into request contents (#1413) - #1428

Open
yoruuuchan wants to merge 1 commit into
AAswordman:devfrom
yoruuuchan:fix/issue-1413-gemini-empty-text-part
Open

yoruuuchan wants to merge 1 commit into
AAswordman:devfrom
yoruuuchan:fix/issue-1413-gemini-empty-text-part

Conversation

@yoruuuchan

Copy link
Copy Markdown
Contributor

变更说明 / Description

背景与动机 / Context and motivation

Gemini 原生端点按 proto 校验请求体。Part.data 是 oneof,正文写成空字符串时该 oneof 没有任何已初始化的成员,整条请求被拒绝,返回 HTTP 400 required oneof field 'data' must have one initialized field。#1413 的最小复现和设备日志都是这个形态。

dev 的 GeminiProvider.buildPartsArray() 在纯文本分支无条件写入 {"text": text},而 PromptTurnKind.ASSISTANT 分支会用它序列化每条历史模型消息,传入的正文是 removeThinkingContent() 剥离思考之后的结果。模型只输出思考、不给正文时(开启思考档位后更容易出现),历史里就留下一条 {"role":"model","parts":[{"text":""}]},此后每一轮重放该历史的请求都 400。

同一个请求构建里还有两条同族路径,命中 Gemini 的另一条校验 contents.parts must not be empty(it must include at least one parts field):

  • 媒体分支:图片链接已不在图片池(LRU 淘汰、重启后缓存清空)时,extractImageLinks 返回空,链接文本又被移除,parts 是空数组,content 仍然被写入请求
  • systemInstruction:系统消息全为空白时写入 {"text": ""},同样是未初始化的 oneof

改动范围 / Changes

  • buildPartsArray():纯文本分支只在正文非空白时写入 text,与同函数媒体分支「先 trim 再判断」的规则对齐
  • 新增 putContent(role, parts):parts 为空时整条 Content 不进入请求,并记一条 debug 日志。所有发出 Content 的分支统一走这个入口,即 ASSISTANT、TOOL_CALL、USER、SUMMARY、TOOL_RESULT 两个分支、工具调用队列 emitQueuedFunctionCallsIfNeeded()、未匹配工具结果 flushOpenFunctionCallsAsUnmatched(),以及关闭工具调用时的通用分支
  • systemInstruction:先剔除空白系统消息再拼接,全部空白时不产生该字段
  • createRequestBody():写入 contents 前确认还剩可用 Content。一条都不剩时抛 EmptyContentsException(IOException 子类),并在 handleRetryableError() 中与 PromptBlockedException 一样直接上抛、不参与重试,因为同一份历史重试只会得到同样的结果
  • 新增文案 gemini_error_empty_contents,同步到含 Gemini 文案段的 8 个 locale。values-ja 本来就没有这一段,保持缺省由系统回退默认文案
  • 新增 JVM 回归测试 GeminiContentsSerializationTest
  • 新增 docs/TODO/issue_1413_gemini_empty_part_20261027/

不包含:

  • 不改动思考档位的请求期校验。issue 备注里 "$providerTypeId option is not supported: " 空 optionId 那条属于配置保存时的档位对齐,见下面「关联 PR 核实」
  • 不把空 parts 填成占位文本。{"text": ""} 正是本 issue 的失败形态,OpenAI/Claude 路径的 [Empty] 占位也不适用:Gemini 允许整条消息缺席,写占位等于往历史里塞模型从未说过的内容
  • 不改响应解析、token 计数与历史合并。preserveThinkInHistory 保留的思考原文、thoughtSignature 挂在 functionCall Part 上的传递、functionCall 与 functionResponse 的配对均保持原样

兼容性与风险 / Compatibility and risks

  • 请求体只在「正文为空」与「parts 为空」这两种本来就会被服务端拒绝的形态上发生变化。非空正文逐字节不变,不 trim、不追加占位
  • 被丢弃的消息只有两类:正文为空的模型轮次;链接媒体已失效且没有其它 Part 的轮次。此前它们会让整轮请求 400,现在其余历史照常发送。Gemini 允许同角色相邻的 Content,工具调用的配对由既有的 open tool call 队列保证
  • 空 contents 由「发出后收到服务端 400 并重试若干次」变为「本地立即给出可读错误」。这是本 PR 唯一新增的失败路径,只在历史里没有任何可用正文时触发,此前该形态同样必然失败
  • 数据格式与持久化不变,无迁移。新增用户可见字符串只有一个

关联 Issue / Related issue

Fixes #1413

关联 PR 核实:#1391 改的是 ModelConfigManager.alignedThinkingOptionId、MiniMax 内置思考规则与默认模型,解决保存思考规则或同供应商下修改模型名、端点之后存档档位失配的问题,没有触碰 GeminiProvider 的请求体序列化。两者不重叠,本 PR 不重复其改动,也不依赖它合入。

认领说明:本 PR 开出前已在 #1413 评论认领;gh issue edit --add-assignee 因当前账号对上游没有 triage 权限失败('yoruuuchan' not found),请维护者协助补 Assignee。

验证方式 / Verification

检查或命令:
.\gradlew.bat :app:testDebugUnitTest --tests "com.ai.assistance.operit.api.chat.llmprovider.GeminiContentsSerializationTest"
.\gradlew.bat :app:testDebugUnitTest
python3 -B ci/script/check_repo_hygiene.py --base f1bbcd80 --candidate HEAD
python3 -B ci/script/check_markdown_links.py --base f1bbcd80 --candidate HEAD
python3 -B ci/script/check_localizations.py --base f1bbcd80 --candidate HEAD
python3 -B -m unittest discover -s ci/test -p 'test_*.py'

环境与变体:Windows,JDK 21.0.12 Temurin,Android SDK 35/36,Gradle 8.13 --no-daemon,:app testDebugUnitTest 变体,基线 upstream/dev f1bbcd80

结果:
- GeminiContentsSerializationTest:13 项通过
- :app:testDebugUnitTest 全量:1356 项通过,0 失败,0 错误,6 跳过
- check_repo_hygiene:errors=0 warnings=0(12 个新增或修改文件)
- check_markdown_links:errors=0 warnings=0
- check_localizations:errors=0 warnings=0
- ci/test:47 项中 45 项通过,2 项报错为 test_android_dependencies 与 test_toolpkg_sync 里需要创建符号链接的用例,Windows 无符号链接权限抛 WinError 1314,与本 PR 无关(本 PR 未改 ci/)
- 未运行 assembleDebug 与真机验证。本地跑 :app:testDebugUnitTest 时排除了 :app:syncSttModelAssets 与 :app:syncMainAssets:STT 模型资产在本机下载后校验不过,且与请求序列化无关;native ripgrep 与 ffmpeg-kit-local.aar 复用本机已有产物。这两项前置由 CI 的 Android lane 自行构建

证据 / Evidence

红/绿对照,证明测试确实锁住了这个缺陷:只回退 buildPartsArray() 的空白判断和 putContent() 的空 parts 判断,其余代码不动,重跑同一个测试类,13 项里 6 项失败,报错正是 issue 报告的三种形态。恢复守卫后 13 项全部通过。

thinking-only assistant turn does not become an empty text part        FAILED  contents[1].parts[0] has empty text
thinking-only assistant turn is dropped in the tool-call protocol too  FAILED  contents[1].parts[0] has empty text
stale thought-signature metadata leaves no part behind                 FAILED  contents[1].parts[0] has empty text
request body carries contents without empty parts                      FAILED  contents[1].parts[0] has empty text
message whose media link no longer resolves ...                        FAILED  expected:<0> but was:<1>
request body is rejected locally when nothing is sendable              FAILED  a history without sendable content must not be sent

新测试用一个不变量遍历整个 contents,把两条服务端校验固化下来:每条 Content 至少一个 Part;每个 Part 至少带一个 data 成员(text、inline_data、functionCall、functionResponse 等),且 text 出现时不得为空串。其余用例覆盖纯思考回复、普通文本、preserveThinkInHistory = true 的思考保留、工具调用与结果的配对、图文混排(inline_data 与 text 同时存在且顺序不变)、图片链接失效、空白系统提示与非空系统提示。

服务端形态依据(同一问题的外部结论,与本 PR 做法一致):

检查清单 / Checklist

  • 我已记录可复现验证和未运行项原因 / Reproducible verification and reasons for unrun checks are recorded
  • 日常开发 PR 的目标分支为 dev;如目标为 main,我已说明这是维护者发布同步 / The target branch is dev for regular work; if it is main, I explained why this is a maintainer-led release sync
  • 我已确认 Candidate checks 覆盖改动范围,并会处理技术失败项 / Candidate checks covers the change scope and technical failures will be addressed
  • 最终 diff 无无关、临时、生成、二进制或敏感内容 / Final diff has no unrelated, temporary, generated, binary, or secret content
  • 已提供对应的回归、UI、文档/字符串或兼容性证据 / Relevant regression, UI, docs/strings, or compatibility evidence is provided

Gemini validates Part.data as a proto3 oneof, so a serialized {"text": ""}
reaches the endpoint as a Part with no data member and the whole request is
rejected with HTTP 400 "required oneof field 'data' must have one initialized
field". A recorded model reply that only carried thinking text strips to an
empty body and hit exactly that path, so one such turn broke every later
request that replayed the history.

buildPartsArray() no longer contributes a Part for a blank body, and every
content now goes through putContent(), which skips a Content whose parts are
empty instead of sending the "contents.parts must not be empty" variant of the
same protocol violation. A message whose media link no longer resolves, and a
blank system prompt, are the other two producers of that shape.

A history that leaves nothing sendable now fails locally as a non-retryable
error instead of being sent and retried against a guaranteed 400.

Thinking text kept by preserveThinkInHistory, thoughtSignature on functionCall
parts, and function call/result pairing keep their existing serialization.
@yoruuuchan

Copy link
Copy Markdown
Contributor Author

补充一条 CI 情况说明,避免维护者以为这条 PR 有自动检查兜底:

ci 检查没有跑,且不是本 PR 的问题。.github/workflows/pr-check.yml 的触发条件仍是

on:
  pull_request:
    branches: [development]

development 已改名为 dev,所以指向 dev 的 PR 不会触发 PR Check,Android JVM tests 与 Android build 也只在 push: branches: [main] 时跑。本 PR 因此没有任何 automated Candidate checks,验证以「验证方式」一节记录的本地结果为准:本地全量 :app:testDebugUnitTest 1356 项通过、hygiene / markdown links / localization 三项 errors=0。

需要提醒的两点:

  1. 「检查清单」里 Candidate checks 那项我留了未勾选,就是这个原因,不是漏做
  2. ci: run PR Check for pull requests into dev #1392(ci: run PR Check for pull requests into dev)合入后,这条 PR 只要再 push 一次就会自动补上全部检查。如果希望我先把 ci: run PR Check for pull requests into dev #1392 的改动 rebase 进来以便让 CI 真正跑起来,我按你的意见处理

另外本地跑单测时我排除了 :app:syncSttModelAssets 与 :app:syncMainAssets:STT 模型资产在这台机器上下载后校验不过,而且与请求序列化无关。这两项前置在 CI 的 Android lane 里由 workflow 自行构建,不影响本 PR 的改动范围。

@CATMIAOZHI CATMIAOZHI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

审计结论:✅ 批准合并

改动(12 个文件,+609/−62):Gemini 请求序列化时不再写出空 Part / 空 Content,从根因上修掉 #1413 的 HTTP 400;新增 GeminiContentsSerializationTest(13 条用例)与 issue 说明文档。

通过的检查:

  • 根因与修复一一对应:纯文本分支 text.isNotBlank() 才写 text(Part.data oneof 未初始化 → 400 的形态);putContent() 统一拦截空 parts 的 Content(contents.parts must not be empty 的另一条校验);全空白系统消息不再产生 systemInstruction;contents 为空时本地抛 EmptyContentsException 而不是发出注定 400 的请求。
  • 发出点无遗漏:grep 新版文件,contentsArray.put 只剩 putContent() 内部一处;ASSISTANT、TOOL_CALL、USER、SUMMARY、TOOL_RESULT 两分支、排队工具调用与未匹配结果冲刷全部走同一入口。
  • 工具调用配对未被破坏:emitQueuedFunctionCallsIfNeeded() 的 parts 必然含 functionCall Part,不会被 putContent 丢弃;flushOpenFunctionCallsAsUnmatched() 只在确有 open call 时才产生 parts;USER/TOOL_RESULT 分支都是先把未匹配 functionResponse 并入 parts 再判断,配对语义与旧代码一致。
  • 不重试链路闭合:createRequestBody() 在 sendMessage 重试循环的 try 内调用,catch 走 handleRetryableError(),其中 EmptyContentsException 与 PromptBlockedException 同列直接上抛,不会用同一份历史反复重试。
  • 非空正文逐字节不变:纯文本分支只加了 isNotBlank() 门槛,写入的仍是原 text(不 trim、不填入替代文本),与 PR 描述一致;preserveThinkInHistory 的思考原文、挂在 functionCall 上的 thoughtSignature 均未动。
  • 文案与文档齐全:gemini_error_empty_contents 已同步到 8 个含 Gemini 文案段的 locale(默认、values-en/es/id/ko/ms/pt-rBR/ro 各 1 处),values-ja 本来就没有该文案段、缺省回退默认文案,与 PR 描述一致;测试 13 条用例与 PR 所述红绿对照(回退两处守卫 6 条失败、恢复后全过)及全量单测说法在代码层面自洽——测试用反射调用私有方法并以不变量遍历全部 Part(每 Part 必带一个 data 成员、text 不为空串、parts 不为空数组),覆盖纯思考回复、保留思考、工具调用配对、图文混排、失效图片链接、空白/非空系统提示、空 contents 本地报错。
  • 目标分支为 dev,非草稿 PR;PR 描述与 diff 一致,无无关文件。

问题:

  1. [question] 丢弃逻辑只记 debug 日志:如果被丢弃的恰好是最新一条用户轮次(例如它唯一的媒体链接已失效且无文本),请求仍会带着其余历史发出,模型会在用户不知情的情况下回复一个未针对该条消息的内容。这个场景概率很低(现发消息的图片链接一般不会立即失效),不阻塞合并;请作者确认这是否可接受,或后续是否值得在丢弃最新用户轮次时给一个可见提示。

后续可跟进(不阻塞):无。

由 水晴喵的muse 审计

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants