Repository navigation
fix(gemini): stop serializing empty parts into request contents (#1413) - #1428
Open
yoruuuchan wants to merge 1 commit into
Open
yoruuuchan wants to merge 1 commit into
yoruuuchan wants to merge 1 commit into
Conversation
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.
Contributor
Author
|
补充一条 CI 情况说明,避免维护者以为这条 PR 有自动检查兜底:
on:
pull_request:
branches: [development]
需要提醒的两点:
另外本地跑单测时我排除了 |
CATMIAOZHI
self-requested a review
October 27, 2026 13:38
CATMIAOZHI
approved these changes
Oct 27, 2026
CATMIAOZHI
left a comment
Collaborator
There was a problem hiding this comment.
审计结论:✅ 批准合并
改动(12 个文件,+609/−62):Gemini 请求序列化时不再写出空 Part / 空 Content,从根因上修掉 #1413 的 HTTP 400;新增 GeminiContentsSerializationTest(13 条用例)与 issue 说明文档。
通过的检查:
- 根因与修复一一对应:纯文本分支
text.isNotBlank()才写text(Part.dataoneof 未初始化 → 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 一致,无无关文件。
问题:
- [question] 丢弃逻辑只记 debug 日志:如果被丢弃的恰好是最新一条用户轮次(例如它唯一的媒体链接已失效且无文本),请求仍会带着其余历史发出,模型会在用户不知情的情况下回复一个未针对该条消息的内容。这个场景概率很低(现发消息的图片链接一般不会立即失效),不阻塞合并;请作者确认这是否可接受,或后续是否值得在丢弃最新用户轮次时给一个可见提示。
后续可跟进(不阻塞):无。
由 水晴喵的muse 审计
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
变更说明 / Description
背景与动机 / Context and motivation
Gemini 原生端点按 proto 校验请求体。
Part.data是 oneof,正文写成空字符串时该 oneof 没有任何已初始化的成员,整条请求被拒绝,返回 HTTP 400required 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):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本来就没有这一段,保持缺省由系统回退默认文案GeminiContentsSerializationTestdocs/TODO/issue_1413_gemini_empty_part_20261027/不包含:
"$providerTypeId option is not supported: "空 optionId 那条属于配置保存时的档位对齐,见下面「关联 PR 核实」{"text": ""}正是本 issue 的失败形态,OpenAI/Claude 路径的[Empty]占位也不适用:Gemini 允许整条消息缺席,写占位等于往历史里塞模型从未说过的内容preserveThinkInHistory保留的思考原文、thoughtSignature挂在 functionCall Part 上的传递、functionCall 与 functionResponse 的配对均保持原样兼容性与风险 / Compatibility and risks
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
证据 / Evidence
红/绿对照,证明测试确实锁住了这个缺陷:只回退
buildPartsArray()的空白判断和putContent()的空 parts 判断,其余代码不动,重跑同一个测试类,13 项里 6 项失败,报错正是 issue 报告的三种形态。恢复守卫后 13 项全部通过。新测试用一个不变量遍历整个
contents,把两条服务端校验固化下来:每条 Content 至少一个 Part;每个 Part 至少带一个data成员(text、inline_data、functionCall、functionResponse等),且text出现时不得为空串。其余用例覆盖纯思考回复、普通文本、preserveThinkInHistory = true的思考保留、工具调用与结果的配对、图文混排(inline_data 与 text 同时存在且顺序不变)、图片链接失效、空白系统提示与非空系统提示。服务端形态依据(同一问题的外部结论,与本 PR 做法一致):
{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"text":""}]}]}得到同样的 400,删掉该 Part 即 200Content参考:一条 Content 至少要有一个 Part检查清单 / Checklist
dev;如目标为main,我已说明这是维护者发布同步 / The target branch isdevfor regular work; if it ismain, I explained why this is a maintainer-led release syncCandidate checks覆盖改动范围,并会处理技术失败项 /Candidate checkscovers the change scope and technical failures will be addressed