mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat: add provider-native request switches (#5254)
This commit is contained in:
@@ -241,6 +241,112 @@ class TestBuildResponsesBodyExtraBody:
|
||||
{"type": "web_search"},
|
||||
]
|
||||
|
||||
def test_responses_web_search_tool_owns_the_local_function(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="gpt-4o",
|
||||
spec=find_by_name("openai"),
|
||||
extra_body={"tools": [{"type": "web_search"}]},
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
assert body["include"] == ["web_search_call.action.sources"]
|
||||
assert provider._should_use_responses_api(None, None) is True
|
||||
|
||||
def test_deepseek_default_search_replaces_the_local_search_function(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="deepseek-v4-flash",
|
||||
spec=find_by_name("deepseek"),
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [{"type": "web_search"}]
|
||||
assert "include" not in body
|
||||
|
||||
def test_explicit_empty_tools_disables_deepseek_default_search(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="deepseek-v4-flash",
|
||||
spec=find_by_name("deepseek"),
|
||||
extra_body={"tools": []},
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [{
|
||||
"type": "function",
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
}]
|
||||
|
||||
def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -1387,6 +1388,91 @@ class TestConsumeSdkStream:
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_web_search_lifecycle_is_streamed_as_tool_progress(self):
|
||||
search_added = SimpleNamespace(
|
||||
type="web_search_call",
|
||||
id="ws_1",
|
||||
status="in_progress",
|
||||
action=SimpleNamespace(type="search"),
|
||||
)
|
||||
search_done = SimpleNamespace(
|
||||
type="web_search_call",
|
||||
id="ws_1",
|
||||
status="completed",
|
||||
action=SimpleNamespace(
|
||||
type="search",
|
||||
queries=["nanobot DeepSeek", "nanobot latest release"],
|
||||
sources=[
|
||||
SimpleNamespace(
|
||||
title="DeepSeek Responses API",
|
||||
url="https://api-docs.deepseek.com/guides/responses_api/",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(status="completed", usage=None, output=[search_done])
|
||||
events = [
|
||||
SimpleNamespace(
|
||||
type="response.output_item.added",
|
||||
output_index=0,
|
||||
item=search_added,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.web_search_call.searching",
|
||||
item_id="ws_1",
|
||||
output_index=0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.web_search_call.completed",
|
||||
item_id="ws_1",
|
||||
output_index=0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.output_item.done",
|
||||
output_index=0,
|
||||
item=search_done,
|
||||
),
|
||||
SimpleNamespace(type="response.completed", response=response),
|
||||
]
|
||||
tool_events: list[dict] = []
|
||||
|
||||
async def stream():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
async def on_tool_event(event: dict) -> None:
|
||||
tool_events.append(event)
|
||||
|
||||
await consume_sdk_stream(stream(), on_tool_call_delta=on_tool_event)
|
||||
|
||||
assert tool_events == [
|
||||
{
|
||||
"kind": "hosted_tool",
|
||||
"phase": "start",
|
||||
"call_id": "ws_1",
|
||||
"name": "web_search",
|
||||
"arguments": {},
|
||||
"result": None,
|
||||
},
|
||||
{
|
||||
"kind": "hosted_tool",
|
||||
"phase": "end",
|
||||
"call_id": "ws_1",
|
||||
"name": "web_search",
|
||||
"arguments": {
|
||||
"query": "nanobot DeepSeek · nanobot latest release",
|
||||
},
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"sources": [{
|
||||
"title": "DeepSeek Responses API",
|
||||
"url": "https://api-docs.deepseek.com/guides/responses_api/",
|
||||
}],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
||||
refusal = "First and second sentence. Done-only. Terminal suffix."
|
||||
|
||||
@@ -139,6 +139,111 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
||||
assert headers["x-grok-model-override"] == "grok-4.5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_parameterized_x_search_is_preserved_without_catalog_lookup(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
hosted_tool = {
|
||||
"type": "x_search",
|
||||
"allowed_x_handles": ["nanobot_ai"],
|
||||
"from_date": "2026-01-01",
|
||||
}
|
||||
provider = XAIGrokProvider(extra_body={
|
||||
"parallel_tool_calls": False,
|
||||
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||
})
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "search"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "x_search",
|
||||
"description": "A colliding local tool",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["parallel_tool_calls"] is False
|
||||
assert bodies[0]["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
hosted_tool,
|
||||
{"type": "code_interpreter", "container": "auto"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monkeypatch) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
provider = XAIGrokProvider(extra_body={"tools": []})
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["tools"] == [{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search(
|
||||
monkeypatch,
|
||||
|
||||
@@ -733,15 +733,19 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
|
||||
},
|
||||
)
|
||||
|
||||
payload = update_provider_settings(
|
||||
{"provider": [provider_name], "proxy": [" http://127.0.0.1:7890 "]}
|
||||
)
|
||||
payload = update_provider_settings({
|
||||
"provider": [provider_name],
|
||||
"proxy": [" http://127.0.0.1:7890 "],
|
||||
"extraBody": [json.dumps({"tools": []})],
|
||||
})
|
||||
|
||||
providers = {row["name"]: row for row in payload["providers"]}
|
||||
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
||||
assert getattr(load_config(config_path).providers, config_attr).proxy == (
|
||||
"http://127.0.0.1:7890"
|
||||
)
|
||||
assert providers[provider_name]["extra_body"] == {"tools": []}
|
||||
assert getattr(load_config(config_path).providers, config_attr).extra_body == {"tools": []}
|
||||
|
||||
cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user