Repository navigation
Conversation
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
There was a problem hiding this comment.
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.
kilisamemarisaaa
left a comment
There was a problem hiding this comment.
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,
Nonecontent 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.
Fixes #10866.
When a fallback (or manually switched) OpenAI-compatible provider answers with
reasoning_contentand tool calls, the assistant turn is stored as an unsignedthinkblock (orcontent: "") plustool_calls. Replaying that history through the Gemini native provider emittedPart(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: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):Part(text=""). A turn with nothing left still gets the existing" "placeholder.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_signatureon cross-provider function calls is a separate problem tracked in #9217 / #9853 and is not touched here.Screenshots or Test Results / 运行截图或测试结果
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
200from the native endpoint again. The original code still gets a400on the same history.Checklist / 检查清单
docs/zhanddocs/enagainst 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 改动,无需更新文档)。Summary by Sourcery
Skip data-less assistant text parts when preparing Gemini conversation history while preserving signed content and empty-turn compatibility.
Bug Fixes:
Tests: