fix(gemini): preserve imported tool history across model switches

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
arcdrake22
2026-08-13 03:07:37 +09:00
committed by Xubin Ren
co-authored by Xubin Ren
parent d3382d7e57
commit 0c684c5a99
2 changed files with 328 additions and 0 deletions
@@ -56,6 +56,8 @@ if TYPE_CHECKING:
# that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None
_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
def _is_hosted_web_search_type(value: object) -> bool:
return isinstance(value, str) and (
@@ -690,6 +692,8 @@ class OpenAICompatProvider(LLMProvider):
if strip_reasoning:
for msg in sanitized:
msg.pop("reasoning_content", None)
if self._spec and self._spec.name == "gemini":
sanitized = self._ensure_gemini_thought_signatures(sanitized)
def map_id(value: Any) -> Any:
if not isinstance(value, str):
@@ -767,6 +771,81 @@ class OpenAICompatProvider(LLMProvider):
clean["content"] = self._coerce_content_to_string(clean.get("content"))
return self._enforce_role_alternation(sanitized)
@staticmethod
def _gemini_thought_signature(tool_call: dict[str, Any]) -> str | None:
"""Return Gemini's thought signature attached to a tool call, if any.
Gemini's OpenAI-compatible endpoint returns tool calls with an
``extra_content`` field: ``{"google": {"thought_signature": "..."}}``.
nanobot preserves it through the parse -> serialize round-trip so
replayed calls stay valid. Calls produced by other providers (e.g.
after a mid-conversation model switch) carry no signature.
"""
extra = tool_call.get("extra_content")
if not isinstance(extra, dict):
return None
google = cast(dict[str, Any], extra).get("google")
if not isinstance(google, dict):
return None
signature = cast(dict[str, Any], google).get("thought_signature")
if isinstance(signature, str) and signature:
return signature
return None
def _ensure_gemini_thought_signatures(
self, messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Keep migrated tool history wire-valid without losing tool context.
Gemini requires the first call in each function-call step to carry a
thought signature. Native parallel calls intentionally leave later
calls unsigned, so they must remain in their original order. For a
fully unsigned step imported from another provider, Google documents
``skip_thought_signature_validator`` as a last-resort migration value.
"""
kept: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
calls = msg.get("tool_calls")
if role != "assistant" or not isinstance(calls, list) or not calls:
kept.append(msg)
continue
call_values = cast(list[object], calls)
typed_calls = [
cast(dict[str, Any], tool_call)
for tool_call in call_values
if isinstance(tool_call, dict)
]
if not typed_calls:
if msg.get("content"):
clean = dict(msg)
clean.pop("tool_calls", None)
kept.append(clean)
continue
clean_calls = typed_calls
if self._gemini_thought_signature(typed_calls[0]) is None:
first = dict(typed_calls[0])
extra_value = first.get("extra_content")
extra = dict(cast(dict[str, Any], extra_value)) if isinstance(
extra_value, dict
) else {}
google_value = extra.get("google")
google = dict(cast(dict[str, Any], google_value)) if isinstance(
google_value, dict
) else {}
google["thought_signature"] = _GEMINI_SKIP_THOUGHT_SIGNATURE
extra["google"] = google
first["extra_content"] = extra
clean_calls = [first, *typed_calls[1:]]
if clean_calls != call_values:
msg = dict(msg)
msg["tool_calls"] = clean_calls
kept.append(msg)
return kept
# ------------------------------------------------------------------
# Build kwargs
# ------------------------------------------------------------------