refactor(provider): preserve extra_content verbatim for Gemini thought_signature round-trip
Replace the flatten/unflatten approach (merging extra_content.google.* into provider_specific_fields then reconstructing) with direct pass-through: parse extra_content as-is, store on ToolCallRequest.extra_content, serialize back untouched. This is lossless, requires no hardcoded field names, and covers all three parsing branches (str, dict, SDK object) plus streaming.
This commit is contained in:
@@ -16,6 +16,7 @@ class ToolCallRequest:
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
extra_content: dict[str, Any] | None = None
|
||||
provider_specific_fields: dict[str, Any] | None = None
|
||||
function_provider_specific_fields: dict[str, Any] | None = None
|
||||
|
||||
@@ -29,22 +30,10 @@ class ToolCallRequest:
|
||||
"arguments": json.dumps(self.arguments, ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
if self.extra_content:
|
||||
tool_call["extra_content"] = self.extra_content
|
||||
if self.provider_specific_fields:
|
||||
# Gemini OpenAI compatibility expects thought signatures in extra_content.google.
|
||||
if "thought_signature" in self.provider_specific_fields:
|
||||
tool_call["extra_content"] = {
|
||||
"google": {
|
||||
"thought_signature": self.provider_specific_fields["thought_signature"],
|
||||
}
|
||||
}
|
||||
other_fields = {
|
||||
k: v for k, v in self.provider_specific_fields.items()
|
||||
if k != "thought_signature"
|
||||
}
|
||||
if other_fields:
|
||||
tool_call["provider_specific_fields"] = other_fields
|
||||
else:
|
||||
tool_call["provider_specific_fields"] = self.provider_specific_fields
|
||||
tool_call["provider_specific_fields"] = self.provider_specific_fields
|
||||
if self.function_provider_specific_fields:
|
||||
tool_call["function"]["provider_specific_fields"] = self.function_provider_specific_fields
|
||||
return tool_call
|
||||
|
||||
@@ -19,42 +19,13 @@ if TYPE_CHECKING:
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
_ALLOWED_MSG_KEYS = frozenset({
|
||||
"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content",
|
||||
"role", "content", "tool_calls", "tool_call_id", "name",
|
||||
"reasoning_content", "extra_content",
|
||||
})
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def _get_attr_or_item(obj: Any, key: str, default: Any = None) -> Any:
|
||||
"""Read an attribute or dict key from provider SDK objects."""
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict[str, Any] | None:
|
||||
"""Return a shallow dict if the value looks mapping-like."""
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tool_call_fields(tc: Any) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Extract provider-specific metadata from a tool call object."""
|
||||
provider_specific_fields = _coerce_dict(_get_attr_or_item(tc, "provider_specific_fields"))
|
||||
extra_content = _coerce_dict(_get_attr_or_item(tc, "extra_content"))
|
||||
google_content = _coerce_dict(_get_attr_or_item(extra_content, "google")) if extra_content else None
|
||||
if google_content:
|
||||
provider_specific_fields = {
|
||||
**(provider_specific_fields or {}),
|
||||
**google_content,
|
||||
}
|
||||
function = _get_attr_or_item(tc, "function")
|
||||
function_provider_specific_fields = _coerce_dict(
|
||||
_get_attr_or_item(function, "provider_specific_fields")
|
||||
)
|
||||
return provider_specific_fields, function_provider_specific_fields
|
||||
_STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"})
|
||||
_STANDARD_FN_KEYS = frozenset({"name", "arguments"})
|
||||
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
@@ -62,6 +33,62 @@ def _short_tool_id() -> str:
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
|
||||
|
||||
def _get(obj: Any, key: str) -> Any:
|
||||
"""Get a value from dict or object attribute, returning None if absent."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key)
|
||||
return getattr(obj, key, None)
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict[str, Any] | None:
|
||||
"""Try to coerce *value* to a dict; return None if not possible or empty."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return value if value else None
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
dumped = model_dump()
|
||||
if isinstance(dumped, dict) and dumped:
|
||||
return dumped
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tc_extras(tc: Any) -> tuple[
|
||||
dict[str, Any] | None,
|
||||
dict[str, Any] | None,
|
||||
dict[str, Any] | None,
|
||||
]:
|
||||
"""Extract (extra_content, provider_specific_fields, fn_provider_specific_fields).
|
||||
|
||||
Works for both SDK objects and dicts. Captures Gemini ``extra_content``
|
||||
verbatim and any non-standard keys on the tool-call / function.
|
||||
"""
|
||||
extra_content = _coerce_dict(_get(tc, "extra_content"))
|
||||
|
||||
tc_dict = _coerce_dict(tc)
|
||||
prov = None
|
||||
fn_prov = None
|
||||
if tc_dict is not None:
|
||||
leftover = {k: v for k, v in tc_dict.items()
|
||||
if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None}
|
||||
if leftover:
|
||||
prov = leftover
|
||||
fn = _coerce_dict(tc_dict.get("function"))
|
||||
if fn is not None:
|
||||
fn_leftover = {k: v for k, v in fn.items()
|
||||
if k not in _STANDARD_FN_KEYS and v is not None}
|
||||
if fn_leftover:
|
||||
fn_prov = fn_leftover
|
||||
else:
|
||||
prov = _coerce_dict(_get(tc, "provider_specific_fields"))
|
||||
fn_obj = _get(tc, "function")
|
||||
if fn_obj is not None:
|
||||
fn_prov = _coerce_dict(_get(fn_obj, "provider_specific_fields"))
|
||||
|
||||
return extra_content, prov, fn_prov
|
||||
|
||||
|
||||
class OpenAICompatProvider(LLMProvider):
|
||||
"""Unified provider for all OpenAI-compatible APIs.
|
||||
|
||||
@@ -332,10 +359,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
args = fn.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
parsed_tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
name=str(fn.get("name") or ""),
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
extra_content=ec,
|
||||
provider_specific_fields=prov,
|
||||
function_provider_specific_fields=fn_prov,
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
@@ -366,17 +397,17 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
tool_calls = []
|
||||
for tc in raw_tool_calls:
|
||||
function = _get_attr_or_item(tc, "function")
|
||||
args = _get_attr_or_item(function, "arguments")
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
provider_specific_fields, function_provider_specific_fields = _extract_tool_call_fields(tc)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
name=_get_attr_or_item(function, "name", ""),
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
provider_specific_fields=provider_specific_fields,
|
||||
function_provider_specific_fields=function_provider_specific_fields,
|
||||
extra_content=ec,
|
||||
provider_specific_fields=prov,
|
||||
function_provider_specific_fields=fn_prov,
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
@@ -390,10 +421,36 @@ class OpenAICompatProvider(LLMProvider):
|
||||
@classmethod
|
||||
def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse:
|
||||
content_parts: list[str] = []
|
||||
tc_bufs: dict[int, dict[str, str]] = {}
|
||||
tc_bufs: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage: dict[str, int] = {}
|
||||
|
||||
def _accum_tc(tc: Any, idx_hint: int) -> None:
|
||||
"""Accumulate one streaming tool-call delta into *tc_bufs*."""
|
||||
tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint
|
||||
buf = tc_bufs.setdefault(tc_index, {
|
||||
"id": "", "name": "", "arguments": "",
|
||||
"extra_content": None, "prov": None, "fn_prov": None,
|
||||
})
|
||||
tc_id = _get(tc, "id")
|
||||
if tc_id:
|
||||
buf["id"] = str(tc_id)
|
||||
fn = _get(tc, "function")
|
||||
if fn is not None:
|
||||
fn_name = _get(fn, "name")
|
||||
if fn_name:
|
||||
buf["name"] = str(fn_name)
|
||||
fn_args = _get(fn, "arguments")
|
||||
if fn_args:
|
||||
buf["arguments"] += str(fn_args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
if ec:
|
||||
buf["extra_content"] = ec
|
||||
if prov:
|
||||
buf["prov"] = prov
|
||||
if fn_prov:
|
||||
buf["fn_prov"] = fn_prov
|
||||
|
||||
for chunk in chunks:
|
||||
if isinstance(chunk, str):
|
||||
content_parts.append(chunk)
|
||||
@@ -418,16 +475,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||
tc_map = cls._maybe_mapping(tc) or {}
|
||||
tc_index = tc_map.get("index", idx)
|
||||
buf = tc_bufs.setdefault(tc_index, {"id": "", "name": "", "arguments": ""})
|
||||
if tc_map.get("id"):
|
||||
buf["id"] = str(tc_map["id"])
|
||||
fn = cls._maybe_mapping(tc_map.get("function")) or {}
|
||||
if fn.get("name"):
|
||||
buf["name"] = str(fn["name"])
|
||||
if fn.get("arguments"):
|
||||
buf["arguments"] += str(fn["arguments"])
|
||||
_accum_tc(tc, idx)
|
||||
usage = cls._extract_usage(chunk_map) or usage
|
||||
continue
|
||||
|
||||
@@ -441,34 +489,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if delta and delta.content:
|
||||
content_parts.append(delta.content)
|
||||
for tc in (delta.tool_calls or []) if delta else []:
|
||||
idx = _get_attr_or_item(tc, "index")
|
||||
if idx is None:
|
||||
continue
|
||||
buf = tc_bufs.setdefault(
|
||||
idx,
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
"provider_specific_fields": None,
|
||||
"function_provider_specific_fields": None,
|
||||
},
|
||||
)
|
||||
tc_id = _get_attr_or_item(tc, "id")
|
||||
if tc_id:
|
||||
buf["id"] = tc_id
|
||||
function = _get_attr_or_item(tc, "function")
|
||||
function_name = _get_attr_or_item(function, "name")
|
||||
if function_name:
|
||||
buf["name"] = function_name
|
||||
arguments = _get_attr_or_item(function, "arguments")
|
||||
if arguments:
|
||||
buf["arguments"] += arguments
|
||||
provider_specific_fields, function_provider_specific_fields = _extract_tool_call_fields(tc)
|
||||
if provider_specific_fields:
|
||||
buf["provider_specific_fields"] = provider_specific_fields
|
||||
if function_provider_specific_fields:
|
||||
buf["function_provider_specific_fields"] = function_provider_specific_fields
|
||||
_accum_tc(tc, getattr(tc, "index", 0))
|
||||
|
||||
return LLMResponse(
|
||||
content="".join(content_parts) or None,
|
||||
@@ -477,8 +498,9 @@ class OpenAICompatProvider(LLMProvider):
|
||||
id=b["id"] or _short_tool_id(),
|
||||
name=b["name"],
|
||||
arguments=json_repair.loads(b["arguments"]) if b["arguments"] else {},
|
||||
provider_specific_fields=b["provider_specific_fields"],
|
||||
function_provider_specific_fields=b["function_provider_specific_fields"],
|
||||
extra_content=b.get("extra_content"),
|
||||
provider_specific_fields=b.get("prov"),
|
||||
function_provider_specific_fields=b.get("fn_prov"),
|
||||
)
|
||||
for b in tc_bufs.values()
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user