fix(providers): preserve nanobot tools with OpenRouter server tools (#5335)

* fix(providers): preserve functions with extra body tools

* docs(providers): clarify extra body tool merging
This commit is contained in:
chengyongru
2026-08-11 18:23:27 +08:00
committed by GitHub
parent 3778e7e628
commit 57d81bc1cd
4 changed files with 78 additions and 8 deletions
+5 -1
View File
@@ -330,7 +330,11 @@ By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normal
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`. Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates: `extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes
ordinary fields through as the SDK `extra_body` value; list-valued `extraBody.tools` is handled
specially and appended after generated function tools. With Responses, configure it in Responses
API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends
`extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
```json ```json
{ {
+23
View File
@@ -100,6 +100,29 @@ Gateway-style setup for model IDs served through OpenRouter.
Use the model ID exactly as OpenRouter lists it. Use the model ID exactly as OpenRouter lists it.
To opt into OpenRouter server-managed search and fetch, add:
```json
{
"providers": {
"openrouter": {
"extraBody": {
"tools": [
{ "type": "openrouter:web_search" },
{ "type": "openrouter:web_fetch" }
]
}
}
}
}
```
Chat Completions-compatible OpenRouter
[server tools](https://openrouter.ai/docs/guides/features/server-tools), such as those above, are
appended to nanobot's generated functions. This keeps unrelated local tools such as `write_file`
available in the same request. Responses-only server tools require an API surface that the
OpenRouter provider does not currently enable.
### Eden AI Gateway ### Eden AI Gateway
Eden AI exposes an OpenAI-compatible chat-completions endpoint at Eden AI exposes an OpenAI-compatible chat-completions endpoint at
+26 -7
View File
@@ -447,6 +447,28 @@ def _merge_unique_list(base: object, override: object) -> object:
return result return result
def _merge_chat_extra_body(
kwargs: dict[str, Any],
extra_body: dict[str, Any],
) -> dict[str, Any]:
"""Merge configured Chat Completions fields without clobbering tools."""
regular_extra = {key: value for key, value in extra_body.items() if key != "tools"}
merged = dict(kwargs)
if regular_extra:
existing = kwargs.get("extra_body", {})
merged["extra_body"] = _deep_merge(existing, regular_extra)
if "tools" in extra_body:
current_tools = kwargs.get("tools")
configured_tools = extra_body["tools"]
if isinstance(current_tools, list) and isinstance(configured_tools, list):
merged["tools"] = [*current_tools, *configured_tools]
else:
merged["tools"] = configured_tools
return merged
def _merge_responses_extra_body( def _merge_responses_extra_body(
body: dict[str, Any], body: dict[str, Any],
extra_body: dict[str, Any], extra_body: dict[str, Any],
@@ -968,14 +990,11 @@ class OpenAICompatProvider(LLMProvider):
if msg.get("role") == "assistant" and "reasoning_content" not in msg: if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = "" msg["reasoning_content"] = ""
# Merge user-configured extra_body last so it can override or # Merge user-configured extra_body last so ordinary fields can override
# extend provider-specific defaults (e.g. chat_template_kwargs, # provider defaults. Keep configured tools at the top level: the SDK
# guided_json, repetition_penalty). Uses recursive merge so # otherwise lets extra_body.tools replace nanobot's generated functions.
# nested dicts like {"chat_template_kwargs": {"enable_thinking": false}}
# do not clobber sibling keys already set by thinking-style logic.
if self._extra_body: if self._extra_body:
existing = kwargs.get("extra_body", {}) kwargs = _merge_chat_extra_body(kwargs, self._extra_body)
kwargs["extra_body"] = _deep_merge(existing, self._extra_body)
return kwargs return kwargs
+24
View File
@@ -117,6 +117,30 @@ class TestBuildKwargsExtraBody:
"chat_template_kwargs": {"enable_thinking": False}, "chat_template_kwargs": {"enable_thinking": False},
} }
def test_extra_body_appends_tools_without_clobbering_functions(self) -> None:
function_tool = {
"type": "function",
"function": {
"name": "write_file",
"description": "Write a local file",
"parameters": {"type": "object"},
},
}
server_tool = {"type": "openrouter:web_search"}
provider = _make_provider({
"tools": [server_tool],
"custom_param": "value",
})
kwargs = provider._build_kwargs(
messages=_simple_messages(),
tools=[function_tool], model=None, max_tokens=100,
temperature=0.1, reasoning_effort=None, tool_choice=None,
)
assert kwargs["tools"] == [function_tool, server_tool]
assert kwargs["extra_body"] == {"custom_param": "value"}
def test_extra_body_merges_with_thinking(self) -> None: def test_extra_body_merges_with_thinking(self) -> None:
"""Config extra_body should merge with (and override) thinking params.""" """Config extra_body should merge with (and override) thinking params."""
from nanobot.providers.registry import ProviderSpec from nanobot.providers.registry import ProviderSpec