Skip to content

[Bug] GeminiProvider 生成空 text part,导致 Gemini 原生端点 400(required oneof field 'data' must have one initialized field) #1413

Open

Description

@xingxinag

环境 / 版本

  • Operit 1.12.2 (versionCode 51),对应 tag v1.12.2 / main dbf71916
  • Provider 类型:GEMINI_GENERIC(自定义端点,指向 new-api 中转站的 Gemini 原生端点)

现象

使用 Gemini 通用 provider 聊天/测试连接时,请求被上游拒绝:

API请求失败:400,
{"error":{"message":"* ***.contents[1].parts[0].data: required oneof field 'data' must have one initialized field
* ***.contents[111].parts[0].data: required oneof field 'data' must have one initialized field
* ***.contents[139].parts[0].data: required oneof field 'data' must have one initialized field
* ***.contents[167].parts[0].data: required oneof field 'data' must have one initialized field","type":"upstream_error","param":""}}

请求端点本身正常:不含空 part 的相同请求可返回 200。

根因推测🤔

GeminiProvider.buildPartsArray() 在纯文本分支里无条件写入 text:

// GeminiProvider.kt
private fun buildPartsArray(text: String): JSONArray {
    val partsArray = JSONArray()
    ...
    } else {
        // 纯文本消息
        partsArray.put(JSONObject().apply {
            put("text", text)          // ← text 为空字符串时,仍会生成 {"text":""}
        })
    }
    return partsArray
}

而 PromptTurnKind.ASSISTANT 分支会对每条历史模型消息调用它:

PromptTurnKind.ASSISTANT -> {
    ...
    } else {
        contentsArray.put(JSONObject().apply {
            put("role", "model")
            put("parts", buildPartsArray(contentWithoutGeminiMeta))   // ← content 可能为空
        })
    }
}

contentWithoutGeminiMeta 是经 removeThinkingContent() 剥离思考后的正文。当模型只输出思考(thought)、没有正文时(例如开启 thinkingLevel 后),该正文变为空字符串,于是生成了:

{
  "role": "model",
  "parts": [ { "text": "" } ]
}

这条空 part 会被写入历史;下一轮请求携带它时,Gemini 的 proto 校验 Part.data 这个 oneof 未初始化,返回 400。

最小复现(服务端侧)

对任意 Gemini 原生端点发送:

POST /v1beta/models/<model>:generateContent
{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"text":""}]}]}

响应与上面的报错完全一致。删除 {"text":""} 这条即 200。

证据(设备日志)

files/logs/operit.log 中真实请求体包含:

{
    "role": "model",
    "parts": [
        {
            "text": ""
        }
    ]
}

小小建议 (可以忽略)

在 GeminiProvider.kt 加空值过滤,避免产生空 part / 空 parts 数组:

// 1) buildPartsArray:文本为空且无图/媒体时,不写入 text
} else {
    if (text.isNotEmpty()) {
        partsArray.put(JSONObject().apply { put("text", text) })
    }
}
// 2) 各 turn 分支:parts 为空则跳过整条 content(Gemini 不允许空 parts)
val parts = buildPartsArray(contentWithoutGeminiMeta)
if (parts.length() > 0) {
    contentsArray.put(JSONObject().apply {
        put("role", "model")   // 或 user,取决于分支
        put("parts", parts)
    })
}

PromptTurnKind.ASSISTANT / USER / SUMMARY / TOOL_CALL 几处均建议加同样的 parts.length() > 0 判断。

备注

  • 该问题与 provider 的思考档位(thinkingOptionId)间接相关:开启思考后模型更容易出现“只有思考、正文为空”的回复,从而产生空 part。
  • 疑似同源问题:ThinkingConfigurationApplier.apply 在 thinkingOptionId 为空且命中 required 规则时抛 "$providerTypeId option is not supported: "(空 optionId),导致请求在发出前就失败;建议一并检查模型切换时是否应自动补默认档位。

Activity

  1. xingxinag commented on Oct 23, 2026

    @xingxinag
    Author

    补充:相关的另一个 bug(空 thinkingOptionId 抛异常)

    在排查上面这个空 part 问题时,同一个 GEMINI_GENERIC 配置还先碰到了另一个报错(请求压根没发出去):

    聊天 失败: GEMINI_GENERIC option is not supported: Tool Call 失败: GEMINI_GENERIC option is not supported:
    

    注意冒号后为空(optionId 为空)。

    根因

    ThinkingConfigurationApplier.apply(ThinkingQualityMapping.kt):

    val thinkingEnabled = enableThinking || mapping.reasoningRequired
    ...
    if (thinkingEnabled && mapping.control == ThinkingQualityControl.LEVELS) {
        val selected = mapping.optionFor(optionId)
            ?: throw IllegalArgumentException("$providerTypeId option is not supported: $optionId")
        selected.actions.forEach { applyAction(requestJson, it) }
    }

    当模型命中一条 required: true 的思考规则(例如内置 gemini-thinking-level 匹配 gemini-(3~9...),gemini-4-argon 命中)时,reasoningRequired 恒为真,即使关掉思考总开关也会进入该分支。若此时模型配置的 thinkingOptionId 为空,optionFor("") 返回 null → 直接抛异常,请求在发出前失败。

    复现条件

    1. 新建/切换到命中 required 规则的模型(如 gemini-4-argon);
    2. 该模型配置的 thinkingOptionId 为空(从未在 UI 上选过档位);
    3. 点测试 / 聊天 / Tool Call → 立即报 ... option is not supported: 。

    原因分析

    thinkingOptionId 只由 UI 交互(ApiConfigDelegate.updateThinkingOptionId)写入,客户端并不会在新建/切换模型时自动补默认档位。而聊天路径又硬性要求必须有一个有效档位,于是形成“不选就报错”的死结。

    查的建议修复(看看就行)

    • A)兜底:在解析出的 mapping 有 options 但 optionId 无效时,自动回退到 mapping.options.first(),而不是抛异常(保留调试日志);
    • **B)写入时修:新建/切换模型时,若命中规则且新模型的 thinkingOptionId 不在规则 options 中,自动写入默认档位(类似现有的 migratePreferencesFromVersion* 逻辑:if (mapping.optionFor(id)==null) mapping.options.firstOrNull()?.id);
    • C) 至少在抛异常前检查 optionId.isBlank(),给出可理解的提示而非空参数。

    ClassicChatSettingsBar 已有类似修正(if (firstOption != null && mapping.optionFor(thinkingOptionId) == null) onThinkingOptionIdChange(firstOption.id)),但那只在打开思考设置面板时触发,“测试连接”路径不会走到。

  2. xingxinag commented on Oct 23, 2026

    @xingxinag
    Author

    设备日志证据

    来自 files/logs/operit.log,本次 400 失败请求的关键几行:

    [108352]         {
    [108353]             "role": "model",
    [108354]             "parts": [
    [108355]                 {
    [108356]                     "text": ""
    [108357]                 }
    [108358]             ]
    
    ……
    
    [109380] 2026-10-23 23:04:28.932 D/GeminiProvider: 请求URL: https://api.<redacted>.com/v1beta/models/gemini-4-argon:streamGenerateContent
    [109397] 2026-10-23 23:04:34.422 D/GeminiProvider: 收到初始响应, 耗时: 5509ms, 状态码: 400
    [109403] 2026-10-23 23:04:34.423 E/GeminiProvider: API请求失败: 400, {"error":{"message":"* ***.contents[1].parts[0].data: required oneof field 'data' must have one initialized field\n* ***.contents[111].parts[0].data: required oneof field 'data' must have one initialized f
    [109405] 2026-10-23 23:04:34.425 W/GeminiProvider: API请求失败: 400, {"error":{"message":"* ***.contents[1].parts[0].data: required oneof field 'data' must have one initialized field\n* ***.contents[111].parts[0].data: required oneof field 'data' must have one initialized f
    [109406] com.ai.assistance.operit.api.chat.llmprovider.GeminiProvider$HttpStatusException: API请求失败: 400, {"error":{"message":"* ***.contents[1].parts[0].data: required oneof field 'data' must have one initialized field\n* ***.contents[111].parts[0].data: required oneof 
    

    可见:

    1. 请求体 contents 里出现了 {"role":"model","parts":[{"text":""}]} 空 part;
    2. 请求走的是原生端点 v1beta/models/<model>:streamGenerateContent;
    3. 上游返回 400,点名 contents[1].parts[0].data(即空 part)。
  3. yoruuuchan commented on Oct 26, 2026

    @yoruuuchan
    Contributor

    已认领这个 issue,正在基于 upstream/dev 修复。

    说明:尝试用 gh issue edit --add-assignee yoruuuchan 认领失败('yoruuuchan' not found),当前账号对上游仓库没有 triage 权限,无法把自己设为 Assignee,因此以评论方式认领。请维护者在方便时帮忙加上 Assignee。

    计划范围:

    • GeminiProvider.buildPartsArray() 纯文本分支不再无条件写入 text
    • 各 turn 分支(USER / ASSISTANT / SUMMARY / TOOL_CALL 等)在 parts 为空时跳过整条 content,避免产生非法空 part 与空 contents
    • 保留有效的思考元数据、工具调用与工具结果序列化
    • 补充序列化回归单测

    关联 PR #1391 覆盖的是 thinkingOptionId 档位对齐(ModelConfigManager / 内置思考规则),与本 issue 的请求体序列化是两个独立问题,不重叠,因此单独开发。

  4. yoruuuchan commented on Oct 27, 2026

    @yoruuuchan
    Contributor

    修复已提交:https://github.com/AAswordman/Operit/pull/1428(目标分支 dev,Fixes #1413)

    按原计划范围完成:

    • buildPartsArray() 纯文本分支不再无条件写入 text,正文为空白时不产生 Part
    • 新增 putContent(role, parts),parts 为空时整条 Content 不进入请求;ASSISTANT / TOOL_CALL / USER / SUMMARY / TOOL_RESULT、工具调用队列、未匹配工具结果 flush 以及关闭工具调用的通用分支全部统一走这个入口
    • 另外两处同族空 Part 一并修掉:图片链接已不在图片池时的空 parts,以及系统消息全为空白时的 systemInstruction
    • 历史里一条可用 Content 都不剩时,本地抛非重试错误给出可读提示,不再发送必然被拒的 "contents": []

    补充一点超出原始推测的发现:你给的 repro 是 {"text": ""},代码里还存在另一种同族非法形态 —— 消息只有图片链接、但图片已不在图片池(LRU 淘汰或重启后清缓存)时,媒体分支得到的是 parts: [],服务端报 contents.parts must not be empty。这一条也一并处理并加了回归用例。

    验证:新增 GeminiContentsSerializationTest 13 项,用不变量遍历每个 Part 与 Content;本地全量 :app:testDebugUnitTest 1356 项通过、0 失败。为确认测试真能抓住这个缺陷,只回退两处守卫重跑,13 项里 6 项失败,报错正是 contents[1].parts[0] has empty text、空 parts 计数 expected:<0> but was:<1> 和「没有可发送内容」漏检,与本 issue 报告形态一致;恢复守卫后全部通过。

    你备注里提到的 ThinkingConfigurationApplier.apply 空 optionId 抛错:已在 PR 里说明它属于配置保存时的档位对齐,由 #1391 覆盖,与本 PR 的请求体序列化不重叠,所以这里没有重复改动。

    提醒:pr-check.yml 的触发分支仍写着已改名的 development,指向 dev 的 PR 不会跑任何 CI 检查,本条 PR 的验证是本地结果。细节见 PR 评论。

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions