Skip to content

fix(gemini): skip empty assistant text parts when replaying history - #10867

Open
t0saki wants to merge 1 commit into
AstrBotDevs:masterfrom
t0saki:fix/10866-gemini-empty-text-part
Open

t0saki wants to merge 1 commit into
AstrBotDevs:masterfrom
t0saki:fix/10866-gemini-empty-text-part

Conversation

@t0saki

@t0saki t0saki commented Oct 29, 2026 •

Copy link
Copy Markdown

Fixes #10866.

When a fallback (or manually switched) OpenAI-compatible provider answers with reasoning_content and tool calls, the assistant turn is stored as an unsigned think block (or content: "") plus tool_calls. Replaying that history through the Gemini native provider emitted Part(text=""). The part carries no data; relays that omit empty strings (e.g. new-api) forward it as a Part without data, and Gemini rejects the whole request:

GenerateContentRequest.contents[1].parts[0].data: required oneof field 'data' must have one initialized field

The request then falls back again, writes another unsigned turn into history, and the conversation never returns to the native provider.

Modifications / 改动点

  • astrbot/core/provider/sources/gemini_source.py (_prepare_conversation):
    • Skip the text part of an assistant turn when its text is empty and it has no thought signature.
    • Skip empty string assistant content instead of emitting Part(text=""). A turn with nothing left still gets the existing " " placeholder.
  • Turns that have text, or an empty text with a signature, are converted exactly as before.
  • tests/test_gemini_source.py: regression tests for an unsigned think-only turn with tool calls, an empty-string turn with tool calls, and an empty assistant turn without tool calls.

The missing thought_signature on cross-provider function calls is a separate problem tracked in #9217 / #9853 and is not touched here.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

$ uv run pytest tests/test_gemini_source.py -q
16 passed            # without the fix: the 2 new tests fail

$ uv run pytest tests -q -k "gemini or provider or fallback"
294 passed, 1 skipped

$ uv run ruff format --check <changed files> && uv run ruff check <changed files>
2 files already formatted
All checks passed!

Also verified on a v4.29.3 Docker deployment (Gemini native provider behind new-api): with this change, plus a thought signature on the unsigned function calls, a real conversation history that was stuck in the fallback loop gets 200 from the native endpoint again. The original code still gets a 400 on the same history.


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc. / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。 (bug fix only, see [Bug] Gemini 原生 provider 回放回退 provider 产生的历史时生成空 text part,导致 400 并持续回退 #10866)
  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above. / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
  • 📚 I checked the affected WebUI instructions and screenshots in docs/zh and docs/en against the changed navigation, page structure, and labels, and updated them in this PR (or explained why no documentation update is needed). / 我已核对 docs/zh 和 docs/en 的相关说明(无 WebUI 改动,无需更新文档)。
  • 🤓 I have ensured that no new dependencies are introduced. / 我确保没有引入新依赖库。
  • 😮 My changes do not introduce malicious code. / 我的更改没有引入恶意代码。

Summary by Sourcery

Skip data-less assistant text parts when preparing Gemini conversation history while preserving signed content and empty-turn compatibility.

Bug Fixes:

  • Prevent Gemini history replay from emitting empty assistant text parts that cause native requests to be rejected, allowing tool-call conversations to recover from fallback loops.

Tests:

  • Add regression coverage for unsigned think-only assistant turns, empty assistant content with tool calls, and empty assistant turns that retain the existing placeholder behavior.

Assistant turns produced by another provider can be stored as an unsigned
think block (or an empty string) next to tool calls. Converting them emitted
Part(text=""), which carries no data; relays that drop empty strings then
forward a Part without data and Gemini rejects the whole request.

Refs AstrBotDevs#10866
Copilot AI balanced review requested due to automatic review settings October 29, 2026 19:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Approved.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@kilisamemarisaaa kilisamemarisaaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I independently checked the history conversion and Google SDK request serialization at 30b74a59bed1b92461dab29c7c274709767a8fba, compared with merge base abf683071952375ec36baa4cad6b6f3d9c9bb03f.

The 16-case probe below exercises the real _prepare_conversation() and google.genai.Client.aio.models.generate_content(), capturing the SDK's JSON through httpx.MockTransport:

  • Base: 8 fail / 8 pass. Unsigned think-only, empty-string/list/text-block histories produce an empty text Part, including turns with no tool, one tool or two tools.
  • PR head: 16 pass. Empty turns without tools retain the single-space placeholder; tool-bearing turns retain their function names, arguments and function responses.
  • All eight captured control JSON objects are identical between revisions, including synthetic thought-signature bytes on text and individual function-call parts, signed-part deduplication, nonempty text, None content with a call, and the empty-user placeholder.
  • Across all 16 bodies, removing only unsigned empty model text parts from the base (or restoring the existing space placeholder when nothing remains) produces exactly the head's body. The input history is unchanged by conversion.

Also ran python -m pytest tests/test_gemini_source.py -q: 16 passed, with the existing audioop deprecation warning. Ruff format/check passes for both changed files. Both source worktrees remain clean.

Environment: Windows x64, Python 3.12.6; the same interpreter/dependency environment for both revisions: google-genai 2.20.0, httpx 0.28.1, pydantic 2.13.5. Missing OpenTelemetry dependencies were supplied in a separate overlay, without modifying the shared environment or either source tree.

Scope: the text,omitempty transformation is modeled explicitly in the probe; I did not run the real new-api relay, call a Gemini service, validate the synthetic signatures, test other SDK versions, or run the complete AstrBot suite. This evidence supports the empty-part conversion change, not resolution of the separate missing-function-signature problem.

Complete independent probe and reproduction

Save the following as probe.py outside the worktrees. Use one environment with the repository's dependencies plus the versions above, set ASTRBOT_ROOT to a separate temporary directory for each run, and run:

python probe.py <base-worktree> base-report.json
python probe.py <head-worktree> head-report.json

The base run exits 1 with eight failed assertions; the head exits 0. Both reports include all 16 captured request bodies. The only HTTP transport is the local mock; its key and signature are dummy fixture values.

"""Compare real AstrBot conversion and Google SDK JSON, without network calls.

Args:
    repo: Exact source worktree to import.
    output: JSON report file outside the source worktree.
"""

import asyncio
import base64
import copy
import importlib.metadata
import inspect
import json
import os
from pathlib import Path
import sys

repo = Path(sys.argv[1]).resolve()
report_path = Path(sys.argv[2]).resolve()
sys.path.insert(0, str(repo))
os.environ.setdefault("TESTING", "true")
os.environ.setdefault("ASTRBOT_TEST_MODE", "true")

import httpx
from google import genai
from google.genai import types

from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI

assert Path(inspect.getfile(ProviderGoogleGenAI)).is_relative_to(repo)
signature = base64.b64encode(b"review-signature-bytes").decode()
call = {"id": "call_1", "type": "function", "function": {
    "name": "web_search", "arguments": '{"q":"weather"}'}}
signed_call = copy.deepcopy(call)
signed_call["extra_content"] = {"google": {"thought_signature": signature}}
second_call = {"id": "call_2", "type": "function", "function": {
    "name": "lookup", "arguments": '{"id":2}'}}
think = [{"type": "think", "think": "private reasoning"}]
signed_think = [{"type": "think", "think": "reasoning", "encrypted": signature}]

# Expected model parts distinguish placeholder turns, text, tool data and signatures.
cases = [
    ("unsigned-think-call", think, [call], None),
    ("empty-string-call", "", [call], None),
    ("empty-list-call", [], [call], None),
    ("empty-text-block-call", [{"type": "text", "text": ""}], [call], None),
    ("unsigned-think-no-call", think, None, " "),
    ("empty-string-no-call", "", None, " "),
    ("empty-list-no-call", [], None, " "),
    ("unsigned-think-two-calls", think, [call, second_call], None),
    ("none-content-call", None, [call], None),
    ("text-string-call", "answer", [call], "answer"),
    ("unsigned-think-and-text-call", think + [{"type": "text", "text": "answer"}], [call], "answer"),
    ("text-string-no-call", "answer", None, "answer"),
    ("signed-think-deduplicated", signed_think, [signed_call], None),
    ("signed-empty-text-preserved", signed_think, [call], ""),
    ("signed-text-and-call", signed_think + [{"type": "text", "text": "answer"}], [signed_call], "answer"),
    ("empty-user-control", "answer", None, "answer"),
]


async def main():
    """Run the conversion/serialization matrix and write all outcomes.

    Returns:
        Number of failed matrix cases.
    """
    captured = []

    async def handle(request):
        assert request.url.host == "review.invalid"
        assert request.url.path.endswith(":generateContent")
        captured.append(json.loads(request.content))
        return httpx.Response(200, json={"candidates": [{"content": {
            "role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP"}]})

    results = []
    client = genai.Client(api_key="local-review-dummy", http_options=types.HttpOptions(
        base_url="https://review.invalid", async_client_args={
            "transport": httpx.MockTransport(handle), "trust_env": False}))
    try:
        for label, content, calls, expected_text in cases:
            assistant = {"role": "assistant", "content": copy.deepcopy(content)}
            if calls is not None:
                assistant["tool_calls"] = copy.deepcopy(calls)
            messages = [{"role": "user", "content": "start"}, assistant]
            if label == "empty-user-control":
                messages[0]["content"] = ""
            if calls:
                messages.extend({"role": "tool", "tool_call_id": tool["id"],
                                 "name": tool["function"]["name"], "content": "tool result"}
                                for tool in calls)
            messages.append({"role": "user", "content": "continue"})
            original = copy.deepcopy(messages)
            result = {"case": label}
            try:
                provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)
                converted = await provider._prepare_conversation({"messages": messages})
                assert messages == original, "conversion mutated input history"
                before_requests = len(captured)
                response = await client.aio.models.generate_content(
                    model="gemini-2.5-flash", contents=converted,
                    config=types.GenerateContentConfig(automatic_function_calling={"disable": True}))
                assert len(captured) == before_requests + 1
                assert response.text == "ok"
                body = captured[-1]
                result["wire"] = body
                model = next(item for item in body["contents"] if item["role"] == "model")
                parts = model["parts"]
                if label != "signed-empty-text-preserved":
                    # Explicit model of the reported relay's text,omitempty behavior,
                    # not a claim that the real new-api relay was run.
                    relay_parts = [{key: value for key, value in part.items()
                                    if not (key == "text" and value == "")} for part in parts]
                    assert all(relay_parts), "unsigned empty text becomes an empty Part"
                actual_text = [part["text"] for part in parts if "text" in part]
                assert actual_text == ([] if expected_text is None else [expected_text]), actual_text
                tool_parts = [part for part in parts if "functionCall" in part]
                assert [part["functionCall"]["name"] for part in tool_parts] == [
                    tool["function"]["name"] for tool in calls or []]
                assert [part["functionCall"]["args"] for part in tool_parts] == [
                    json.loads(tool["function"]["arguments"]) for tool in calls or []]
                for part, tool in zip(tool_parts, calls or []):
                    expected_signature = tool.get("extra_content", {}).get("google", {}).get("thought_signature")
                    assert part.get("thoughtSignature") == expected_signature
                if label in ("signed-empty-text-preserved", "signed-text-and-call"):
                    assert parts[0]["thoughtSignature"] == signature
                if calls:
                    responses = [part["functionResponse"] for item in body["contents"]
                                 for part in item["parts"] if "functionResponse" in part]
                    assert [item["name"] for item in responses] == [tool["function"]["name"] for tool in calls]
                    assert all(item["response"]["content"] == "tool result" for item in responses)
                if label == "empty-user-control":
                    assert body["contents"][0]["parts"][0]["text"] == " "
                result["passed"] = True
            except Exception as error:
                result.update(passed=False, error=f"{type(error).__name__}: {error}")
            results.append(result)
            print(json.dumps({key: value for key, value in result.items() if key != "wire"}))
    finally:
        await client.aio.aclose()
        client.close()

    passed = sum(item["passed"] for item in results)
    report = {"repo": str(repo), "module": inspect.getfile(ProviderGoogleGenAI),
              "versions": {name: importlib.metadata.version(name) for name in
                           ("google-genai", "httpx", "pydantic", "opentelemetry-api")},
              "passed": passed, "failed": len(results) - passed,
              "requests": len(captured), "cases": results}
    assert len(results) == len(captured) == 16
    report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
    print(json.dumps({key: value for key, value in report.items() if key != "cases"}))
    return len(results) - passed


if __name__ == "__main__":
    sys.exit(bool(asyncio.run(main())))

To check the cross-revision comparison after the two runs:

import copy
import json
from pathlib import Path

base = json.loads(Path("base-report.json").read_text(encoding="utf-8"))
head = json.loads(Path("head-report.json").read_text(encoding="utf-8"))
assert len(base["cases"]) == len(head["cases"]) == 16
controls = 0
for before, after in zip(base["cases"], head["cases"]):
    assert before["case"] == after["case"]
    expected = copy.deepcopy(before["wire"])
    for content in expected["contents"]:
        if content["role"] == "model":
            content["parts"] = [
                part for part in content["parts"]
                if not (part.get("text") == "" and not part.get("thoughtSignature"))
            ] or [{"text": " "}]
    assert expected == after["wire"], before["case"]
    if before["passed"]:
        assert before["wire"] == after["wire"], before["case"]
        controls += 1
assert controls == 8
print("16 normalized payloads match; 8 control payloads unchanged")

Prepared and verified with Codex assistance.

This branch has not been deployed

No deployments
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.

[Bug] Gemini 原生 provider 回放回退 provider 产生的历史时生成空 text part,导致 400 并持续回退

3 participants