feat: add provider-native request switches (#5254)

This commit is contained in:
chengyongru 2026-08-05 18:26:39 +08:00 committed by GitHub
parent 5a1ab44baa
commit 67805f5db8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1130 additions and 52 deletions

View File

@ -347,6 +347,36 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
} }
``` ```
The WebUI's OpenAI web-search switch writes the corresponding `apiType` and `extraBody.tools`
fields. A hosted search tool replaces nanobot's same-name local `web_search` function for that
request, while other tools such as `web_fetch` remain available.
</details>
<details>
<summary><b>DeepSeek native web search</b></summary>
DeepSeek V4 Flash uses DeepSeek's native Responses API. Its provider-hosted web search is
enabled by default because it does not require a separate paid add-on. Turn it off from the
WebUI provider settings, or with:
```json
{
"providers": {
"deepseek": {
"apiKey": "${DEEPSEEK_API_KEY}",
"extraBody": {
"tools": []
}
}
}
}
```
The switch applies to `deepseek-v4-flash`; DeepSeek models that remain on Chat Completions
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and
their opaque output items are preserved for multi-turn Responses state replay.
</details> </details>
<a id="responses-state-and-compaction"></a> <a id="responses-state-and-compaction"></a>
@ -695,7 +725,7 @@ Then run:
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
To opt in to Codex Fast mode, merge this provider setting into `config.json`: Codex Fast mode can be enabled from the WebUI provider settings, or with:
```json ```json
{ {
@ -709,9 +739,9 @@ To opt in to Codex Fast mode, merge this provider setting into `config.json`:
} }
``` ```
`priority` is the Responses API request value used by Codex Fast mode. The setting only works The switch sends the Responses API `service_tier: "priority"` value. It only works for models
for models and accounts that support Fast mode; remove `service_tier` to return to standard and accounts that support Fast mode; turn the switch off to return to standard processing.
processing. Fast mode consumes Codex credits at a higher rate. See the Fast mode consumes Codex credits at a higher rate. See the
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details. [OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems). For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
@ -735,6 +765,8 @@ The provider reads xAI's model catalog and includes the server-hosted `x_search`
tool only when the selected model advertises `supportsBackendSearch`. Models tool only when the selected model advertises `supportsBackendSearch`. Models
without that capability continue normally without hosted X Search. When enabled, without that capability continue normally without hosted X Search. When enabled,
searches run inside xAI's Responses API and citations arrive as inline links. searches run inside xAI's Responses API and citations arrive as inline links.
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
public OAuth client and proxy contract used by public OAuth client and proxy contract used by

View File

@ -262,9 +262,9 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
} }
``` ```
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. `providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
### Custom OpenAI-Compatible Endpoint ### Custom OpenAI-Compatible Endpoint
@ -528,6 +528,8 @@ When enabled, Grok can search current X posts and return inline source links
without invoking a local nanobot tool. Credentials are stored under the without invoking a local nanobot tool. Credentials are stored under the
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
`config.json` and not in Grok Build's credential file. `config.json` and not in Grok Build's credential file.
Hosted X Search remains enabled by default and can be disabled with the WebUI
switch or `providers.xaiGrok.extraBody.tools: []`.
The login is xAI subscription OAuth, not X Developer OAuth. It follows the The login is xAI subscription OAuth, not X Developer OAuth. It follows the
public client contract documented and implemented by public client contract documented and implemented by

View File

@ -56,6 +56,32 @@ if TYPE_CHECKING:
# that ``unittest.mock.patch`` can find and replace it. # that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None AsyncOpenAI: Any = None
def _is_hosted_web_search_type(value: object) -> bool:
return isinstance(value, str) and (
value == "web_search" or value.startswith("web_search_")
)
def _is_hosted_web_search_tool(tool: object) -> bool:
if not isinstance(tool, dict):
return False
tool_type = cast(dict[object, object], tool).get("type")
return _is_hosted_web_search_type(tool_type)
def _is_named_function_tool(tool: object, name: str) -> bool:
"""Return whether a Responses tool is a function with the given name."""
if not isinstance(tool, dict):
return False
record = cast(dict[object, object], tool)
if record.get("type") != "function":
return False
function = record.get("function")
if isinstance(function, dict):
return cast(dict[object, object], function).get("name") == name
return record.get("name") == name
_ALLOWED_MSG_KEYS = frozenset({ _ALLOWED_MSG_KEYS = frozenset({
"role", "content", "tool_calls", "tool_call_id", "name", "role", "content", "tool_calls", "tool_call_id", "name",
"reasoning_content", "extra_content", "reasoning_content", "extra_content",
@ -469,7 +495,7 @@ class OpenAICompatProvider(LLMProvider):
self.default_model = default_model self.default_model = default_model
self.extra_headers = extra_headers or {} self.extra_headers = extra_headers or {}
self._spec = spec self._spec = spec
self._extra_body = extra_body or {} self._extra_body = dict(extra_body or {})
self._api_type = api_type if spec and spec.name == "openai" else "auto" self._api_type = api_type if spec and spec.name == "openai" else "auto"
self._extra_query = extra_query or {} self._extra_query = extra_query or {}
self._proxy = proxy or None self._proxy = proxy or None
@ -974,8 +1000,8 @@ class OpenAICompatProvider(LLMProvider):
provider_responses = spec_name in ("openai", "github_copilot") provider_responses = spec_name in ("openai", "github_copilot")
if not provider_responses and not model_responses: if not provider_responses and not model_responses:
return False return False
if self._api_type == "responses": if self._responses_is_required():
# Explicit configuration means Responses is mandatory; do not # Explicit Responses-only request fields are mandatory; do not
# consult the circuit breaker or fall back to Chat Completions. # consult the circuit breaker or fall back to Chat Completions.
return True return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"): if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
@ -994,6 +1020,25 @@ class OpenAICompatProvider(LLMProvider):
return self._responses_circuit_allows_probe(model, reasoning_effort) return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_is_required(self) -> bool:
return self._api_type == "responses" or self._hosted_web_search_enabled()
def _hosted_web_search_enabled(self) -> bool:
extra_body = getattr(self, "_extra_body", {})
configured_tools = extra_body.get("tools")
if "tools" in extra_body:
return isinstance(configured_tools, list) and any(
_is_hosted_web_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
return bool(
self._spec
and any(
_is_hosted_web_search_type(tool_type)
for tool_type in getattr(self._spec, "responses_default_tools", ())
)
)
def _responses_state_provider(self) -> str: def _responses_state_provider(self) -> str:
spec_name = self._spec.name if self._spec is not None else "custom" spec_name = self._spec.name if self._spec is not None else "custom"
effective_base = self._effective_base or "https://api.openai.com/v1" effective_base = self._effective_base or "https://api.openai.com/v1"
@ -1157,9 +1202,38 @@ class OpenAICompatProvider(LLMProvider):
body["tool_choice"] = tool_choice or "auto" body["tool_choice"] = tool_choice or "auto"
extra_body = getattr(self, "_extra_body", {}) extra_body = getattr(self, "_extra_body", {})
default_tools = getattr(self._spec, "responses_default_tools", ())
if "tools" not in extra_body and default_tools:
body["tools"] = [
*cast(list[object], body.get("tools", [])),
*({"type": tool_type} for tool_type in default_tools),
]
if extra_body: if extra_body:
body = _merge_responses_extra_body(body, extra_body) body = _merge_responses_extra_body(body, extra_body)
if self._hosted_web_search_enabled():
configured_tools = body.get("tools")
if isinstance(configured_tools, list):
managed_tools: list[object] = []
hosted_search_seen = False
for tool in cast(list[object], configured_tools):
if _is_named_function_tool(tool, "web_search"):
continue
if _is_hosted_web_search_tool(tool):
if hosted_search_seen:
continue
hosted_search_seen = True
managed_tools.append(tool)
body["tools"] = managed_tools
if self._spec and self._spec.name == "openai":
source_include = "web_search_call.action.sources"
configured_include = body.get("include")
if isinstance(configured_include, list):
if source_include not in configured_include:
body["include"] = [*configured_include, source_include]
else:
body["include"] = [source_include]
return body return body
async def _create_response_with_compaction_fallback( async def _create_response_with_compaction_fallback(
@ -1771,7 +1845,7 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses": if self._responses_is_required():
raise raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
@ -1867,7 +1941,7 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses": if self._responses_is_required():
raise raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise

View File

@ -89,6 +89,77 @@ def _response_object_list(value: object) -> list[dict[str, Any]]:
] ]
def _hosted_web_search_event(
event: object,
event_type: object,
) -> dict[str, Any] | None:
"""Map the official web-search output item pair onto normal tool progress."""
if event_type not in {"response.output_item.added", "response.output_item.done"}:
return None
event_object = _response_object(event) or {}
item = _response_object(event_object.get("item")) or {}
if item.get("type") != "web_search_call":
return None
call_id = item.get("id") or item.get("call_id") or event_object.get("item_id")
if not isinstance(call_id, str) or not call_id:
return None
action = _response_object(item.get("action")) or {}
raw_queries = action.get("queries")
queries = (
[
query.strip()
for query in cast(list[object], raw_queries)
if isinstance(query, str) and query.strip()
][:4]
if isinstance(raw_queries, list)
else []
)
query = " · ".join(queries)
if not query:
query = next(
(
value.strip()
for key in ("query", "pattern", "url")
if isinstance((value := action.get(key)), str) and value.strip()
),
"",
)
arguments = {"query": query[:1000]} if query else {}
phase = "start" if event_type == "response.output_item.added" else "end"
result: dict[str, Any] | None = None
if phase == "end":
status = item.get("status")
result = {"status": status if isinstance(status, str) else "completed"}
raw_sources = action.get("sources")
if isinstance(raw_sources, list):
sources: list[dict[str, str]] = []
for raw_source in cast(list[object], raw_sources):
source = _response_object(raw_source) or {}
url = source.get("url")
if not isinstance(url, str) or not url.strip():
continue
visible_source = {"url": url.strip()[:2048]}
title = source.get("title")
if isinstance(title, str) and title.strip():
visible_source["title"] = title.strip()[:300]
sources.append(visible_source)
if len(sources) == 8:
break
if sources:
result["sources"] = sources
return {
"kind": "hosted_tool",
"phase": phase,
"call_id": call_id,
"name": "web_search",
"arguments": arguments,
"result": result,
}
def map_finish_reason(status: str | None) -> str: def map_finish_reason(status: str | None) -> str:
"""Map a Responses API status string to a Chat-Completions-style finish_reason.""" """Map a Responses API status string to a Chat-Completions-style finish_reason."""
return FINISH_REASON_MAP.get(status or "completed", "stop") return FINISH_REASON_MAP.get(status or "completed", "stop")
@ -269,11 +340,14 @@ async def consume_sse_with_reasoning(
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
async for event in iter_sse(response): async for event in iter_sse(response):
if on_response_event: if on_response_event:
await on_response_event(event) await on_response_event(event)
event_type = event.get("type") event_type = event.get("type")
if on_tool_call_delta and (
hosted_event := _hosted_web_search_event(event, event_type)
):
await on_tool_call_delta(hosted_event)
if event_type == "response.output_item.added": if event_type == "response.output_item.added":
item = _as_json_object(event.get("item")) or {} item = _as_json_object(event.get("item")) or {}
if item.get("type") == "function_call": if item.get("type") == "function_call":
@ -555,10 +629,13 @@ async def consume_sdk_stream(
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
async for raw_event in stream: async for raw_event in stream:
event: Any = raw_event event: Any = raw_event
event_type = getattr(event, "type", None) event_type = getattr(event, "type", None)
if on_tool_call_delta and (
hosted_event := _hosted_web_search_event(event, event_type)
):
await on_tool_call_delta(hosted_event)
if event_type == "response.output_item.added": if event_type == "response.output_item.added":
item = getattr(event, "item", None) item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call": if item and getattr(item, "type", None) == "function_call":

View File

@ -116,6 +116,10 @@ class ProviderSpec:
# Flash is supported before V4 Pro). # Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = () responses_models: tuple[str, ...] = ()
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
# supplies the hosted-tool selection. Values are raw Responses tool types.
responses_default_tools: tuple[str, ...] = ()
# When the model returns content as a list of {"type":"thinking",...} + # When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into # {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use # reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@ -479,6 +483,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://api.deepseek.com", default_api_base="https://api.deepseek.com",
thinking_style="thinking_type", thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",), responses_models=("deepseek-v4-flash",),
responses_default_tools=("web_search",),
), ),
# Gemini: Google's OpenAI-compatible endpoint # Gemini: Google's OpenAI-compatible endpoint
ProviderSpec( ProviderSpec(

View File

@ -46,6 +46,19 @@ _SENSITIVE_ERROR_KEYS = {
} }
def _is_hosted_x_search_tool(value: object) -> bool:
if not isinstance(value, dict):
return False
return cast(dict[object, object], value).get("type") == "x_search"
def _is_named_x_search_tool(value: object) -> bool:
if not isinstance(value, dict):
return False
record = cast(dict[object, object], value)
return record.get("type") == "function" and record.get("name") == "x_search"
class XAIGrokProvider(LLMProvider): class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools.""" """Call xAI's subscription proxy and expose supported hosted tools."""
@ -112,13 +125,27 @@ class XAIGrokProvider(LLMProvider):
stage = "oauth_token" stage = "oauth_token"
try: try:
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy) token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
stage = "model_capabilities" configured_tools = self._extra_body.get("tools")
supports_backend_search = await self._supports_backend_search(token, wire_model) tools_are_explicit = "tools" in self._extra_body
configured_hosted_search = (
isinstance(configured_tools, list)
and any(
_is_hosted_x_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
)
supports_backend_search = False
if not tools_are_explicit:
stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model)
converted_tools = convert_tools(tools or []) converted_tools = convert_tools(tools or [])
if supports_backend_search: if isinstance(configured_tools, list):
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
if supports_backend_search or configured_hosted_search:
converted_tools = [ converted_tools = [
tool for tool in converted_tools if tool.get("name") != "x_search" tool for tool in converted_tools if not _is_named_x_search_tool(tool)
] ]
if supports_backend_search:
converted_tools.append({"type": "x_search"}) converted_tools.append({"type": "x_search"})
body: dict[str, Any] = { body: dict[str, Any] = {
@ -137,7 +164,13 @@ class XAIGrokProvider(LLMProvider):
"reasoning": _build_reasoning_options(reasoning_effort), "reasoning": _build_reasoning_options(reasoning_effort),
} }
if self._extra_body: if self._extra_body:
body.update(self._extra_body) body.update({
key: value
for key, value in self._extra_body.items()
if key != "tools"
})
if tools_are_explicit and not isinstance(configured_tools, list):
body["tools"] = configured_tools
headers = _build_headers(token.access, wire_model) headers = _build_headers(token.access, wire_model)
stage = "xai_request" stage = "xai_request"

View File

@ -241,6 +241,112 @@ class TestBuildResponsesBodyExtraBody:
{"type": "web_search"}, {"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: def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
api_key="test-key", api_key="test-key",

View File

@ -2,6 +2,7 @@
import json import json
from io import StringIO from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -1387,6 +1388,91 @@ class TestConsumeSdkStream:
assert tool_calls == [] assert tool_calls == []
assert finish_reason == "stop" 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 @pytest.mark.asyncio
async def test_refusal_events_reconcile_parts_and_terminal_output(self): async def test_refusal_events_reconcile_parts_and_terminal_output(self):
refusal = "First and second sentence. Done-only. Terminal suffix." refusal = "First and second sentence. Done-only. Terminal suffix."

View File

@ -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" 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 @pytest.mark.asyncio
async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search( async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search(
monkeypatch, monkeypatch,

View File

@ -733,15 +733,19 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
}, },
) )
payload = update_provider_settings( payload = update_provider_settings({
{"provider": [provider_name], "proxy": [" http://127.0.0.1:7890 "]} "provider": [provider_name],
) "proxy": [" http://127.0.0.1:7890 "],
"extraBody": [json.dumps({"tools": []})],
})
providers = {row["name"]: row for row in payload["providers"]} providers = {row["name"]: row for row in payload["providers"]}
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890" assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
assert getattr(load_config(config_path).providers, config_attr).proxy == ( assert getattr(load_config(config_path).providers, config_attr).proxy == (
"http://127.0.0.1:7890" "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": [" "]}) cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})

View File

@ -276,6 +276,52 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]); const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
type ProviderRequestOption = {
kind: "priority" | "hosted_tool";
titleKey: string;
title: string;
helpKey: string;
help: string;
toolType?: "web_search" | "x_search";
defaultEnabled?: boolean;
forceResponses?: boolean;
};
const PROVIDER_REQUEST_OPTIONS: Partial<Record<string, ProviderRequestOption[]>> = {
openai_codex: [{
kind: "priority",
titleKey: "settings.providers.capabilityFastMode",
title: "Fast mode",
helpKey: "settings.providers.capabilityFastModeHelp",
help: "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
}],
openai: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityOpenAISearch",
title: "OpenAI web search",
helpKey: "settings.providers.capabilityOpenAISearchHelp",
help: "Allow compatible Responses API models to search the web. Search activity appears in chat.",
toolType: "web_search",
forceResponses: true,
}],
deepseek: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityDeepSeekSearch",
title: "DeepSeek web search",
helpKey: "settings.providers.capabilityDeepSeekSearchHelp",
help: "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
toolType: "web_search",
defaultEnabled: true,
}],
xai_grok: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityXSearch",
title: "X Search",
helpKey: "settings.providers.capabilityXSearchHelp",
help: "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
toolType: "x_search",
defaultEnabled: true,
}],
};
const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__"; const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [ const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
"extra_headers", "extra_headers",
@ -306,6 +352,70 @@ function providerJsonValue(value: Record<string, unknown> | null | undefined): s
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : ""; return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
} }
function parseProviderExtraBody(value: string): Record<string, unknown> | null {
if (!value.trim()) return {};
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function isHostedSearchTool(tool: unknown, toolType: "web_search" | "x_search"): boolean {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const configuredType = (tool as Record<string, unknown>).type;
if (typeof configuredType !== "string") return false;
return configuredType === toolType
|| (toolType === "web_search" && configuredType.startsWith("web_search_"));
}
function hasHostedSearchTool(value: unknown, toolType: "web_search" | "x_search"): boolean {
return Array.isArray(value) && value.some((tool) => isHostedSearchTool(tool, toolType));
}
function providerRequestOptionEnabled(
option: ProviderRequestOption,
extraBody: Record<string, unknown>,
): boolean {
if (option.kind === "priority") return extraBody.service_tier === "priority";
if (Object.prototype.hasOwnProperty.call(extraBody, "tools")) {
return hasHostedSearchTool(extraBody.tools, option.toolType!);
}
return option.defaultEnabled === true;
}
function updateProviderRequestOption(
option: ProviderRequestOption,
enabled: boolean,
form: ProviderForm,
): Partial<ProviderForm> {
const extraBody = { ...(parseProviderExtraBody(form.extraBody) ?? {}) };
if (option.kind === "priority") {
if (enabled) extraBody.service_tier = "priority";
else if (extraBody.service_tier === "priority") delete extraBody.service_tier;
} else {
const toolType = option.toolType!;
const tools = Array.isArray(extraBody.tools)
? extraBody.tools.filter((tool) => !isHostedSearchTool(tool, toolType))
: [];
if (enabled) tools.push({ type: toolType });
if (tools.length || option.defaultEnabled) {
extraBody.tools = tools;
} else {
delete extraBody.tools;
}
}
return {
extraBody: providerJsonValue(extraBody),
...(option.forceResponses && enabled
? { apiType: "responses" as const }
: {}),
};
}
function providerFormFromRow( function providerFormFromRow(
provider: SettingsPayload["providers"][number], provider: SettingsPayload["providers"][number],
): ProviderForm { ): ProviderForm {
@ -3852,6 +3962,62 @@ function ModelAdvancedFields({
); );
} }
function ProviderRequestOptions({
providerName,
form,
onChange,
}: {
providerName: string;
form: ProviderForm;
onChange: (value: Partial<ProviderForm>) => void;
}) {
const { t } = useTranslation();
const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
if (options.length === 0) return null;
const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
{options.map((option, index) => {
const title = tx(option.titleKey, option.title);
const Icon = option.kind === "priority" ? Zap : Globe2;
const checked = providerRequestOptionEnabled(option, extraBody);
return (
<div
key={option.titleKey}
className={cn(
"flex items-center justify-between gap-4 px-4 py-3",
index > 0 && "border-t border-border/45",
)}
>
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground">
<Icon className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">{title}</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{tx(option.helpKey, option.help)}
</p>
</div>
</div>
<ToggleButton
checked={checked}
onChange={(enabled) => onChange(
updateProviderRequestOption(option, enabled, form),
)}
ariaLabel={title}
label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</div>
);
})}
</div>
);
}
function ProviderAdvancedOptions({ function ProviderAdvancedOptions({
fields, fields,
form, form,
@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({
</label> </label>
) : null} ) : null}
</div> </div>
{footer ? ( </div>
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border/45 pt-3"> ) : null}
{footer} {footer ? (
</div> <div className="flex items-center justify-end gap-2 border-t border-border/45 py-3">
) : null} {footer}
</div> </div>
) : null} ) : null}
</div> </div>
@ -4320,6 +4486,11 @@ function ProvidersSettings({
</Button> </Button>
</div> </div>
</div> </div>
<ProviderRequestOptions
providerName={provider.name}
form={form}
onChange={(value) => onChangeProviderForm(provider.name, value)}
/>
{supportsOauthAdvancedSettings ? ( {supportsOauthAdvancedSettings ? (
<ProviderAdvancedOptions <ProviderAdvancedOptions
fields={advancedFields} fields={advancedFields}
@ -4445,6 +4616,11 @@ function ProvidersSettings({
className="h-9 rounded-full text-[13px]" className="h-9 rounded-full text-[13px]"
/> />
</label> </label>
<ProviderRequestOptions
providerName={provider.name}
form={form}
onChange={(value) => onChangeProviderForm(provider.name, value)}
/>
<ProviderAdvancedOptions <ProviderAdvancedOptions
fields={advancedFields} fields={advancedFields}
form={form} form={form}

View File

@ -1,6 +1,7 @@
import { import {
canonicalToolTrace, canonicalToolTrace,
mergeToolProgressEvents, mergeToolProgressEvents,
mergeToolProgressTraceLines,
mergeUniqueToolTraceLines, mergeUniqueToolTraceLines,
} from "@/lib/tool-traces"; } from "@/lib/tool-traces";
import type { UIMediaAttachment, UIMessage } from "@/lib/types"; import type { UIMediaAttachment, UIMessage } from "@/lib/types";
@ -56,8 +57,15 @@ function canMergeAdjacentProgress(
} }
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage { function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []); const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
const traces = incoming.toolEvents?.length
? mergeToolProgressTraceLines(
messageTraces(previous),
previous.toolEvents,
messageTraces(incoming),
incoming.toolEvents ?? [],
)
: mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])]; const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]); const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);

View File

@ -27,7 +27,7 @@ export function describeTraceLine(
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed); !!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
if (/search/i.test(name)) { if (/search/i.test(name)) {
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed; const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args;
return { return {
kind: "search", kind: "search",
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"), label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),

View File

@ -105,7 +105,7 @@ export function presentWebSearchAction(
if (target === "x") { if (target === "x") {
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`; return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
} }
return queryTarget ? `${verb} ${queryTarget}` : verb; return queryTarget ? `${verb} ${queryTarget}` : `${verb} the web`;
} }
function mergeWebSearchRun( function mergeWebSearchRun(

View File

@ -4,7 +4,7 @@ import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
import { import {
mergeToolProgressEvents, mergeToolProgressEvents,
mergeUniqueToolTraceLines, mergeToolProgressTraceLines,
normalizeToolProgressEvents, normalizeToolProgressEvents,
toolTraceLinesFromEvents, toolTraceLinesFromEvents,
} from "@/lib/tool-traces"; } from "@/lib/tool-traces";
@ -1213,18 +1213,24 @@ export function useNanobotStream(
: last.content : last.content
? [last.content] ? [last.content]
: []; : [];
const mergedEvents = visibleStructuredEvents.length > 0
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents;
const mergedLines = visibleStructuredEvents.length > 0 const mergedLines = visibleStructuredEvents.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines) ? mergeToolProgressTraceLines(
previousTraces,
last.toolEvents,
structuredLines,
visibleStructuredEvents,
)
: null; : null;
const merged: UIMessage = { const merged: UIMessage = {
...last, ...last,
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines], traces: mergedLines ?? [...previousTraces, ...lines],
content: mergedLines content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1] ? mergedLines[mergedLines.length - 1]
: lines[lines.length - 1], : lines[lines.length - 1],
toolEvents: visibleStructuredEvents.length toolEvents: mergedEvents,
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId, activitySegmentId: last.activitySegmentId ?? segmentId,
...turn, ...turn,
}; };

View File

@ -514,7 +514,15 @@
"profile": "Profile", "profile": "Profile",
"extraHeaders": "Extra headers", "extraHeaders": "Extra headers",
"extraBody": "Extra body", "extraBody": "Extra body",
"extraQuery": "Extra query" "extraQuery": "Extra query",
"capabilityFastMode": "Fast mode",
"capabilityFastModeHelp": "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
"capabilityDeepSeekSearch": "DeepSeek web search",
"capabilityDeepSeekSearchHelp": "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
"capabilityXSearch": "X Search",
"capabilityXSearchHelp": "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
"capabilityOpenAISearch": "OpenAI web search",
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
}, },
"legal": { "legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement." "thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."

View File

@ -365,7 +365,15 @@
"profile": "Perfil", "profile": "Perfil",
"extraHeaders": "Cabeceras adicionales", "extraHeaders": "Cabeceras adicionales",
"extraBody": "Cuerpo adicional", "extraBody": "Cuerpo adicional",
"extraQuery": "Parámetros de consulta adicionales" "extraQuery": "Parámetros de consulta adicionales",
"capabilityFastMode": "Modo rápido",
"capabilityFastModeHelp": "Usa el nivel de servicio prioritario de OpenAI para responder más rápido. Esto consume créditos más deprisa.",
"capabilityDeepSeekSearch": "Búsqueda web de DeepSeek",
"capabilityDeepSeekSearchHelp": "Permite que DeepSeek V4 Flash busque en la web mediante Responses API. La actividad de búsqueda aparece en el chat.",
"capabilityXSearch": "Búsqueda en X",
"capabilityXSearchHelp": "Permite que los modelos Grok compatibles usen X Search alojado por xAI. La actividad de búsqueda aparece en el chat.",
"capabilityOpenAISearch": "Búsqueda web de OpenAI",
"capabilityOpenAISearchHelp": "Permite que los modelos compatibles con Responses API busquen en la web. La actividad de búsqueda aparece en el chat."
}, },
"image": { "image": {
"selectProvider": "Seleccionar proveedor", "selectProvider": "Seleccionar proveedor",

View File

@ -365,7 +365,15 @@
"profile": "Profil", "profile": "Profil",
"extraHeaders": "En-têtes supplémentaires", "extraHeaders": "En-têtes supplémentaires",
"extraBody": "Corps supplémentaire", "extraBody": "Corps supplémentaire",
"extraQuery": "Paramètres de requête supplémentaires" "extraQuery": "Paramètres de requête supplémentaires",
"capabilityFastMode": "Mode rapide",
"capabilityFastModeHelp": "Utilise le niveau de service prioritaire dOpenAI pour accélérer les réponses. Les crédits sont consommés plus rapidement.",
"capabilityDeepSeekSearch": "Recherche web DeepSeek",
"capabilityDeepSeekSearchHelp": "Permet à DeepSeek V4 Flash de rechercher sur le web via lAPI Responses. Lactivité de recherche apparaît dans le chat.",
"capabilityXSearch": "Recherche sur X",
"capabilityXSearchHelp": "Permet aux modèles Grok compatibles dutiliser X Search hébergé par xAI. Lactivité de recherche apparaît dans le chat.",
"capabilityOpenAISearch": "Recherche web OpenAI",
"capabilityOpenAISearchHelp": "Permet aux modèles compatibles avec lAPI Responses de rechercher sur le web. Lactivité de recherche apparaît dans le chat."
}, },
"image": { "image": {
"selectProvider": "Choisir un fournisseur", "selectProvider": "Choisir un fournisseur",

View File

@ -365,7 +365,15 @@
"profile": "Profil", "profile": "Profil",
"extraHeaders": "Header tambahan", "extraHeaders": "Header tambahan",
"extraBody": "Body tambahan", "extraBody": "Body tambahan",
"extraQuery": "Parameter kueri tambahan" "extraQuery": "Parameter kueri tambahan",
"capabilityFastMode": "Mode cepat",
"capabilityFastModeHelp": "Gunakan tingkat layanan prioritas OpenAI agar respons lebih cepat. Kredit akan terpakai lebih cepat.",
"capabilityDeepSeekSearch": "Pencarian web DeepSeek",
"capabilityDeepSeekSearchHelp": "Izinkan DeepSeek V4 Flash mencari di web melalui Responses API. Aktivitas pencarian ditampilkan di chat.",
"capabilityXSearch": "Pencarian X",
"capabilityXSearchHelp": "Izinkan model Grok yang didukung menggunakan X Search yang dihosting xAI. Aktivitas pencarian ditampilkan di chat.",
"capabilityOpenAISearch": "Pencarian web OpenAI",
"capabilityOpenAISearchHelp": "Izinkan model yang kompatibel dengan Responses API mencari di web. Aktivitas pencarian ditampilkan di chat."
}, },
"image": { "image": {
"selectProvider": "Pilih penyedia", "selectProvider": "Pilih penyedia",

View File

@ -365,7 +365,15 @@
"profile": "プロファイル", "profile": "プロファイル",
"extraHeaders": "追加ヘッダー", "extraHeaders": "追加ヘッダー",
"extraBody": "追加ボディ", "extraBody": "追加ボディ",
"extraQuery": "追加クエリ" "extraQuery": "追加クエリ",
"capabilityFastMode": "高速モード",
"capabilityFastModeHelp": "OpenAI の優先サービス階層を使用して応答を高速化します。クレジットの消費も速くなります。",
"capabilityDeepSeekSearch": "DeepSeek ウェブ検索",
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash が Responses API 経由でウェブを検索できるようにします。検索状況はチャットに表示されます。",
"capabilityXSearch": "X 検索",
"capabilityXSearchHelp": "対応する Grok モデルが xAI ホストの X Search を使用できるようにします。検索状況はチャットに表示されます。",
"capabilityOpenAISearch": "OpenAI ウェブ検索",
"capabilityOpenAISearchHelp": "Responses API 対応モデルがウェブを検索できるようにします。検索状況はチャットに表示されます。"
}, },
"image": { "image": {
"selectProvider": "プロバイダーを選択", "selectProvider": "プロバイダーを選択",

View File

@ -365,7 +365,15 @@
"profile": "프로필", "profile": "프로필",
"extraHeaders": "추가 헤더", "extraHeaders": "추가 헤더",
"extraBody": "추가 본문", "extraBody": "추가 본문",
"extraQuery": "추가 쿼리" "extraQuery": "추가 쿼리",
"capabilityFastMode": "고속 모드",
"capabilityFastModeHelp": "OpenAI 우선 서비스 등급을 사용해 더 빠르게 응답합니다. 크레딧도 더 빠르게 소모됩니다.",
"capabilityDeepSeekSearch": "DeepSeek 웹 검색",
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash가 Responses API를 통해 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
"capabilityXSearch": "X 검색",
"capabilityXSearchHelp": "지원되는 Grok 모델이 xAI 호스팅 X Search를 사용하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
"capabilityOpenAISearch": "OpenAI 웹 검색",
"capabilityOpenAISearchHelp": "Responses API 호환 모델이 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다."
}, },
"image": { "image": {
"selectProvider": "제공자 선택", "selectProvider": "제공자 선택",

View File

@ -514,7 +514,15 @@
"profile": "Perfil", "profile": "Perfil",
"extraHeaders": "Cabeçalhos adicionais", "extraHeaders": "Cabeçalhos adicionais",
"extraBody": "Corpo adicional", "extraBody": "Corpo adicional",
"extraQuery": "Parâmetros de consulta adicionais" "extraQuery": "Parâmetros de consulta adicionais",
"capabilityFastMode": "Modo rápido",
"capabilityFastModeHelp": "Usa o nível de serviço prioritário da OpenAI para respostas mais rápidas. Isso consome créditos mais rapidamente.",
"capabilityDeepSeekSearch": "Pesquisa web do DeepSeek",
"capabilityDeepSeekSearchHelp": "Permite que o DeepSeek V4 Flash pesquise na web pela Responses API. A atividade de pesquisa aparece no chat.",
"capabilityXSearch": "Pesquisa no X",
"capabilityXSearchHelp": "Permite que modelos Grok compatíveis usem o X Search hospedado pela xAI. A atividade de pesquisa aparece no chat.",
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
}, },
"legal": { "legal": {
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso." "thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."

View File

@ -365,7 +365,15 @@
"profile": "Hồ sơ", "profile": "Hồ sơ",
"extraHeaders": "Header bổ sung", "extraHeaders": "Header bổ sung",
"extraBody": "Body bổ sung", "extraBody": "Body bổ sung",
"extraQuery": "Tham số truy vấn bổ sung" "extraQuery": "Tham số truy vấn bổ sung",
"capabilityFastMode": "Chế độ nhanh",
"capabilityFastModeHelp": "Dùng tầng dịch vụ ưu tiên của OpenAI để phản hồi nhanh hơn. Tín dụng cũng sẽ được tiêu thụ nhanh hơn.",
"capabilityDeepSeekSearch": "Tìm kiếm web DeepSeek",
"capabilityDeepSeekSearchHelp": "Cho phép DeepSeek V4 Flash tìm kiếm trên web qua Responses API. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
"capabilityXSearch": "Tìm kiếm X",
"capabilityXSearchHelp": "Cho phép các mô hình Grok được hỗ trợ dùng X Search do xAI lưu trữ. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
"capabilityOpenAISearch": "Tìm kiếm web OpenAI",
"capabilityOpenAISearchHelp": "Cho phép các mô hình tương thích với Responses API tìm kiếm trên web. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện."
}, },
"image": { "image": {
"selectProvider": "Chọn nhà cung cấp", "selectProvider": "Chọn nhà cung cấp",

View File

@ -514,7 +514,15 @@
"profile": "配置档案", "profile": "配置档案",
"extraHeaders": "额外请求头", "extraHeaders": "额外请求头",
"extraBody": "额外请求体", "extraBody": "额外请求体",
"extraQuery": "额外查询参数" "extraQuery": "额外查询参数",
"capabilityFastMode": "Fast 模式",
"capabilityFastModeHelp": "通过 OpenAI 优先服务层获取更快响应,会更快消耗额度。",
"capabilityDeepSeekSearch": "DeepSeek 联网搜索",
"capabilityDeepSeekSearchHelp": "允许 DeepSeek V4 Flash 通过 Responses API 搜索网络,并在对话中展示搜索过程。",
"capabilityXSearch": "X 搜索",
"capabilityXSearchHelp": "允许受支持的 Grok 模型使用 xAI 托管的 X Search并在对话中展示搜索过程。",
"capabilityOpenAISearch": "OpenAI 联网搜索",
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
}, },
"legal": { "legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。" "thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"

View File

@ -365,7 +365,15 @@
"profile": "設定檔", "profile": "設定檔",
"extraHeaders": "額外請求標頭", "extraHeaders": "額外請求標頭",
"extraBody": "額外請求本文", "extraBody": "額外請求本文",
"extraQuery": "額外查詢參數" "extraQuery": "額外查詢參數",
"capabilityFastMode": "Fast 模式",
"capabilityFastModeHelp": "透過 OpenAI 優先服務層取得更快回應,也會更快消耗額度。",
"capabilityDeepSeekSearch": "DeepSeek 網路搜尋",
"capabilityDeepSeekSearchHelp": "允許 DeepSeek V4 Flash 透過 Responses API 搜尋網路,並在對話中顯示搜尋過程。",
"capabilityXSearch": "X 搜尋",
"capabilityXSearchHelp": "允許支援的 Grok 模型使用 xAI 託管的 X Search並在對話中顯示搜尋過程。",
"capabilityOpenAISearch": "OpenAI 網路搜尋",
"capabilityOpenAISearchHelp": "允許相容 Responses API 的模型搜尋網路,並在對話中顯示搜尋過程。"
}, },
"image": { "image": {
"selectProvider": "選擇供應商", "selectProvider": "選擇供應商",

View File

@ -16,7 +16,10 @@ export function formatToolCallTrace(call: unknown): string | null {
if (!name) return null; if (!name) return null;
const args = item.function?.arguments ?? item.arguments; const args = item.function?.arguments ?? item.arguments;
if (typeof args === "string" && args.trim()) return `${name}(${args})`; if (typeof args === "string" && args.trim()) return `${name}(${args})`;
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`; if (args && typeof args === "object") {
const serialized = JSON.stringify(args);
return serialized === "{}" || serialized === "[]" ? `${name}()` : `${name}(${serialized})`;
}
return `${name}()`; return `${name}()`;
} }
@ -116,3 +119,24 @@ export function mergeUniqueToolTraceLines(
} }
return { traces, added }; return { traces, added };
} }
export function mergeToolProgressTraceLines(
previousTraces: string[],
previousEvents: ToolProgressEvent[] | undefined,
incomingTraces: string[],
incomingEvents: ToolProgressEvent[],
): string[] {
const mergedEvents = mergeToolProgressEvents(previousEvents, incomingEvents);
const candidates = mergeUniqueToolTraceLines(previousTraces, incomingTraces).traces;
const eventTraceKeys = new Set([
...toolTraceLinesFromEvents(previousEvents),
...toolTraceLinesFromEvents(incomingEvents),
].map(canonicalToolTrace));
const nonEventTraces = candidates.filter(
(line) => !eventTraceKeys.has(canonicalToolTrace(line)),
);
return mergeUniqueToolTraceLines(
nonEventTraces,
toolTraceLinesFromEvents(mergedEvents),
).traces;
}

View File

@ -1165,12 +1165,13 @@ describe("AgentActivityCluster", () => {
id: "search-start", id: "search-start",
role: "tool", role: "tool",
kind: "trace", kind: "trace",
content: line, content: "web_search()",
traces: [line], traces: ["web_search()"],
toolEvents: [{ toolEvents: [{
phase: "start", phase: "start",
call_id: "hosted-search-1",
name: "web_search", name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" }, arguments: {},
}], }],
createdAt: 1, createdAt: 1,
}, },
@ -1182,6 +1183,7 @@ describe("AgentActivityCluster", () => {
traces: [line], traces: [line],
toolEvents: [{ toolEvents: [{
phase: "error", phase: "error",
call_id: "hosted-search-1",
name: "web_search", name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" }, arguments: { query: "site:linkedin.com/company Evomap startup" },
error: "Search provider rate limited the request", error: "Search provider rate limited the request",

View File

@ -580,7 +580,7 @@ describe("webui API helpers", () => {
await updateProviderSettings("tok", { await updateProviderSettings("tok", {
provider: "xai_grok", provider: "xai_grok",
proxy: "http://127.0.0.1:7890", proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}', extraBody: '{"tools":[]}',
}); });
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
@ -590,7 +590,7 @@ describe("webui API helpers", () => {
Authorization: "Bearer tok", Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
proxy: "http://127.0.0.1:7890", proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}', extraBody: '{"tools":[]}',
})), })),
}, },
}), }),

View File

@ -3101,6 +3101,200 @@ describe("SettingsView Apps catalog", () => {
); );
}); });
it("maps provider request switches to raw extraBody fields", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "grok@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "codex@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
advanced_fields: ["extra_body"],
extra_body: null,
},
{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: null,
},
];
const payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
const [input] = args;
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
const xSearch = screen.getByRole("switch", { name: "X Search" });
expect(xSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(xSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok",
expect.anything(),
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openai_codex",
expect.anything(),
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(deepSeekSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "DeepSeek web search" }),
).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "OpenAI web search" }),
).not.toBeInTheDocument());
await waitFor(() => {
const requestUpdates = fetchMock.mock.calls
.filter(([input]) => String(input).startsWith("/api/settings/provider/update?"))
.map(([input, init]) => {
const provider = new URLSearchParams(String(input).split("?")[1]).get("provider");
const headers = init?.headers as Record<string, string>;
const values = JSON.parse(decodeURIComponent(
headers["X-Nanobot-Provider-Values"],
)) as { apiType?: string; extraBody?: string };
return [provider, {
...(values.apiType ? { apiType: values.apiType } : {}),
extraBody: JSON.parse(values.extraBody ?? "{}"),
}] as const;
});
expect(requestUpdates).toEqual([
["xai_grok", { extraBody: { tools: [] } }],
["openai_codex", { extraBody: { service_tier: "priority" } }],
["deepseek", { extraBody: { tools: [] } }],
["openai", {
apiType: "responses",
extraBody: { tools: [{ type: "web_search" }] },
}],
]);
});
});
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: {
metadata: { owner: "legacy-config" },
tools: [
{ type: "web_search_preview", search_context_size: "medium" },
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
],
},
}],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(searchSwitch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const updateCall = fetchMock.mock.calls.find(
([input]) => String(input).startsWith("/api/settings/provider/update?"),
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
const values = JSON.parse(decodeURIComponent(
headers["X-Nanobot-Provider-Values"],
)) as { extraBody: string };
expect(JSON.parse(values.extraBody)).toEqual({
metadata: { owner: "legacy-config" },
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
});
});
});
it("creates a custom provider with folded advanced request settings", async () => { it("creates a custom provider with folded advanced request settings", async () => {
const base = settingsPayload(); const base = settingsPayload();
let payload: SettingsPayload = { let payload: SettingsPayload = {

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
canonicalToolTrace, canonicalToolTrace,
mergeToolProgressTraceLines,
mergeUniqueToolTraceLines, mergeUniqueToolTraceLines,
} from "@/lib/tool-traces"; } from "@/lib/tool-traces";
@ -26,4 +27,18 @@ describe("tool trace identity", () => {
added: true, added: true,
}); });
}); });
it("replaces an empty streaming placeholder when hosted search arguments arrive", () => {
expect(mergeToolProgressTraceLines(
["web_search()"],
[{ phase: "start", call_id: "ws-1", name: "web_search", arguments: {} }],
['web_search({"query":"nanobot news"})'],
[{
phase: "end",
call_id: "ws-1",
name: "web_search",
arguments: { query: "nanobot news" },
}],
)).toEqual(['web_search({"query":"nanobot news"})']);
});
}); });

View File

@ -8,6 +8,10 @@ function describeTrace(line: string, status: GenericToolStatus = "done") {
} }
describe("trace activity semantics", () => { describe("trace activity semantics", () => {
it("uses readable copy when a hosted search query is unavailable", () => {
expect(describeTrace("web_search()").label).toBe("Searched the web");
});
it.each([ it.each([
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""], ['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"], ['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],

View File

@ -610,6 +610,51 @@ describe("useNanobotStream", () => {
]); ]);
}); });
it("replaces a hosted search placeholder when its query arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-hosted-search", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-hosted-search", {
event: "message",
chat_id: "chat-hosted-search",
text: "web_search()",
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "ws-1",
name: "web_search",
arguments: {},
}],
});
fake.emit("chat-hosted-search", {
event: "message",
chat_id: "chat-hosted-search",
text: "",
kind: "progress",
tool_events: [{
phase: "end",
call_id: "ws-1",
name: "web_search",
arguments: { query: "nanobot news" },
result: { status: "completed" },
}],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].traces).toEqual([
'web_search({"query":"nanobot news"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([{
phase: "end",
call_id: "ws-1",
arguments: { query: "nanobot news" },
}]);
});
it("keeps phase updates when a tool event trace line is deduped", () => { it("keeps phase updates when a tool event trace line is deduped", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {