From 57d81bc1cd15cb03097af259c5933e8805a6bbc6 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:23:27 +0800 Subject: [PATCH] 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 --- docs/configuration.md | 6 +++- docs/providers.md | 23 ++++++++++++++ nanobot/providers/openai_compat_provider.py | 33 ++++++++++++++++----- tests/providers/test_extra_body_config.py | 24 +++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 82134f792..c1c371b4f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`. -`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 { diff --git a/docs/providers.md b/docs/providers.md index 73d13b22a..3c3bc2b3d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -100,6 +100,29 @@ Gateway-style setup for model IDs served through OpenRouter. 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 exposes an OpenAI-compatible chat-completions endpoint at diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index d6e7e47bd..4254a0323 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -447,6 +447,28 @@ def _merge_unique_list(base: object, override: object) -> object: 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( 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: msg["reasoning_content"] = "" - # Merge user-configured extra_body last so it can override or - # extend provider-specific defaults (e.g. chat_template_kwargs, - # guided_json, repetition_penalty). Uses recursive merge so - # nested dicts like {"chat_template_kwargs": {"enable_thinking": false}} - # do not clobber sibling keys already set by thinking-style logic. + # Merge user-configured extra_body last so ordinary fields can override + # provider defaults. Keep configured tools at the top level: the SDK + # otherwise lets extra_body.tools replace nanobot's generated functions. if self._extra_body: - existing = kwargs.get("extra_body", {}) - kwargs["extra_body"] = _deep_merge(existing, self._extra_body) + kwargs = _merge_chat_extra_body(kwargs, self._extra_body) return kwargs diff --git a/tests/providers/test_extra_body_config.py b/tests/providers/test_extra_body_config.py index 004c355ab..e1f263740 100644 --- a/tests/providers/test_extra_body_config.py +++ b/tests/providers/test_extra_body_config.py @@ -117,6 +117,30 @@ class TestBuildKwargsExtraBody: "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: """Config extra_body should merge with (and override) thinking params.""" from nanobot.providers.registry import ProviderSpec