From 67805f5db8dc8d3b1a61fbc0f29bb1a6d010b86e Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:26:39 +0800 Subject: [PATCH] feat: add provider-native request switches (#5254) --- docs/configuration.md | 40 +++- docs/providers.md | 6 +- nanobot/providers/openai_compat_provider.py | 84 +++++++- nanobot/providers/openai_responses/parsing.py | 81 +++++++- nanobot/providers/registry.py | 5 + nanobot/providers/xai_grok_provider.py | 43 +++- tests/providers/test_extra_body_config.py | 106 ++++++++++ tests/providers/test_openai_responses.py | 86 ++++++++ tests/providers/test_xai_grok_provider.py | 105 ++++++++++ tests/webui/test_settings_api.py | 10 +- .../src/components/settings/SettingsView.tsx | 186 ++++++++++++++++- .../thread/activity/activity-message-model.ts | 10 +- .../thread/activity/trace-activity-model.ts | 2 +- .../thread/activity/web-search-model.ts | 2 +- webui/src/hooks/useNanobotStream.ts | 20 +- webui/src/i18n/locales/en/common.json | 10 +- webui/src/i18n/locales/es/common.json | 10 +- webui/src/i18n/locales/fr/common.json | 10 +- webui/src/i18n/locales/id/common.json | 10 +- webui/src/i18n/locales/ja/common.json | 10 +- webui/src/i18n/locales/ko/common.json | 10 +- webui/src/i18n/locales/pt-BR/common.json | 10 +- webui/src/i18n/locales/vi/common.json | 10 +- webui/src/i18n/locales/zh-CN/common.json | 10 +- webui/src/i18n/locales/zh-TW/common.json | 10 +- webui/src/lib/tool-traces.ts | 26 ++- .../src/tests/agent-activity-cluster.test.tsx | 8 +- webui/src/tests/api.test.ts | 4 +- webui/src/tests/settings-view.test.tsx | 194 ++++++++++++++++++ webui/src/tests/tool-traces.test.ts | 15 ++ webui/src/tests/trace-activity-model.test.ts | 4 + webui/src/tests/useNanobotStream.test.tsx | 45 ++++ 32 files changed, 1130 insertions(+), 52 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cdd8cf539..6b3bcb591 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. + + + +
+DeepSeek native web search + +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. +
@@ -695,7 +725,7 @@ Then run: 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 { @@ -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 -for models and accounts that support Fast mode; remove `service_tier` to return to standard -processing. Fast mode consumes Codex credits at a higher rate. See the +The switch sends the Responses API `service_tier: "priority"` value. It only works for models +and accounts that support Fast mode; turn the switch off to return to standard processing. +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. 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 without that capability continue normally without hosted X Search. When enabled, 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 public OAuth client and proxy contract used by diff --git a/docs/providers.md b/docs/providers.md index ad39a8be5..73d13b22a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 @@ -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 active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in `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 public client contract documented and implemented by diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 7b6238f22..e66f98d3d 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -56,6 +56,32 @@ if TYPE_CHECKING: # that ``unittest.mock.patch`` can find and replace it. 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({ "role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "extra_content", @@ -469,7 +495,7 @@ class OpenAICompatProvider(LLMProvider): self.default_model = default_model self.extra_headers = extra_headers or {} 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._extra_query = extra_query or {} self._proxy = proxy or None @@ -974,8 +1000,8 @@ class OpenAICompatProvider(LLMProvider): provider_responses = spec_name in ("openai", "github_copilot") if not provider_responses and not model_responses: return False - if self._api_type == "responses": - # Explicit configuration means Responses is mandatory; do not + if self._responses_is_required(): + # Explicit Responses-only request fields are mandatory; do not # consult the circuit breaker or fall back to Chat Completions. return True 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) + 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: spec_name = self._spec.name if self._spec is not None else "custom" 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" 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: 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 async def _create_response_with_compaction_fallback( @@ -1771,7 +1845,7 @@ class OpenAICompatProvider(LLMProvider): # falling back to /chat/completions cannot succeed and would # hide the real error. raise - if self._api_type == "responses": + if self._responses_is_required(): raise if not self._should_fallback_from_responses_error(responses_error): raise @@ -1867,7 +1941,7 @@ class OpenAICompatProvider(LLMProvider): # falling back to /chat/completions cannot succeed and would # hide the real error. raise - if self._api_type == "responses": + if self._responses_is_required(): raise if not self._should_fallback_from_responses_error(responses_error): raise diff --git a/nanobot/providers/openai_responses/parsing.py b/nanobot/providers/openai_responses/parsing.py index da8dcf514..021569aa3 100644 --- a/nanobot/providers/openai_responses/parsing.py +++ b/nanobot/providers/openai_responses/parsing.py @@ -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: """Map a Responses API status string to a Chat-Completions-style finish_reason.""" return FINISH_REASON_MAP.get(status or "completed", "stop") @@ -269,11 +340,14 @@ async def consume_sse_with_reasoning( refusal_seen = False refusal_deltas: dict[tuple[str | None, int | None], str] = {} emitted_refusal_text = "" - async for event in iter_sse(response): if on_response_event: await on_response_event(event) 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": item = _as_json_object(event.get("item")) or {} if item.get("type") == "function_call": @@ -555,10 +629,13 @@ async def consume_sdk_stream( refusal_seen = False refusal_deltas: dict[tuple[str | None, int | None], str] = {} emitted_refusal_text = "" - async for raw_event in stream: event: Any = raw_event 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": item = getattr(event, "item", None) if item and getattr(item, "type", None) == "function_call": diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 5318dcf72..a5efa41f4 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -116,6 +116,10 @@ class ProviderSpec: # Flash is supported before V4 Pro). 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",...} + # {"type":"text",...} blocks, extract the thinking text into # reasoning_content. Mistral's Magistral / reasoning-enabled responses use @@ -479,6 +483,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://api.deepseek.com", thinking_style="thinking_type", responses_models=("deepseek-v4-flash",), + responses_default_tools=("web_search",), ), # Gemini: Google's OpenAI-compatible endpoint ProviderSpec( diff --git a/nanobot/providers/xai_grok_provider.py b/nanobot/providers/xai_grok_provider.py index b99f95696..86d0cf945 100644 --- a/nanobot/providers/xai_grok_provider.py +++ b/nanobot/providers/xai_grok_provider.py @@ -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): """Call xAI's subscription proxy and expose supported hosted tools.""" @@ -112,13 +125,27 @@ class XAIGrokProvider(LLMProvider): stage = "oauth_token" try: token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy) - stage = "model_capabilities" - supports_backend_search = await self._supports_backend_search(token, wire_model) + configured_tools = self._extra_body.get("tools") + 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 []) - 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 = [ - 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"}) body: dict[str, Any] = { @@ -137,7 +164,13 @@ class XAIGrokProvider(LLMProvider): "reasoning": _build_reasoning_options(reasoning_effort), } 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) stage = "xai_request" diff --git a/tests/providers/test_extra_body_config.py b/tests/providers/test_extra_body_config.py index 5b69348c2..004c355ab 100644 --- a/tests/providers/test_extra_body_config.py +++ b/tests/providers/test_extra_body_config.py @@ -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", diff --git a/tests/providers/test_openai_responses.py b/tests/providers/test_openai_responses.py index 00f35d8b8..af02add99 100644 --- a/tests/providers/test_openai_responses.py +++ b/tests/providers/test_openai_responses.py @@ -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." diff --git a/tests/providers/test_xai_grok_provider.py b/tests/providers/test_xai_grok_provider.py index f988ded7a..8ba95da26 100644 --- a/tests/providers/test_xai_grok_provider.py +++ b/tests/providers/test_xai_grok_provider.py @@ -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, diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 7574dce30..3d92b0356 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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": [" "]}) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 6e52c3a3c..194163a31 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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 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> = { + 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_ADVANCED_FIELDS: ProviderAdvancedField[] = [ "extra_headers", @@ -306,6 +352,70 @@ function providerJsonValue(value: Record | null | undefined): s return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : ""; } +function parseProviderExtraBody(value: string): Record | null { + if (!value.trim()) return {}; + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : 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).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, +): 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 { + 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( provider: SettingsPayload["providers"][number], ): ProviderForm { @@ -3852,6 +3962,62 @@ function ModelAdvancedFields({ ); } +function ProviderRequestOptions({ + providerName, + form, + onChange, +}: { + providerName: string; + form: ProviderForm; + onChange: (value: Partial) => 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 ( +
+ {options.map((option, index) => { + const title = tx(option.titleKey, option.title); + const Icon = option.kind === "priority" ? Zap : Globe2; + const checked = providerRequestOptionEnabled(option, extraBody); + return ( +
0 && "border-t border-border/45", + )} + > +
+ + + +
+

{title}

+

+ {tx(option.helpKey, option.help)} +

+
+
+ onChange( + updateProviderRequestOption(option, enabled, form), + )} + ariaLabel={title} + label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} + /> +
+ ); + })} +
+ ); +} + function ProviderAdvancedOptions({ fields, form, @@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({ ) : null} - {footer ? ( -
- {footer} -
- ) : null} + + ) : null} + {footer ? ( +
+ {footer}
) : null} @@ -4320,6 +4486,11 @@ function ProvidersSettings({ + onChangeProviderForm(provider.name, value)} + /> {supportsOauthAdvancedSettings ? ( + onChangeProviderForm(provider.name, value)} + /> 0 + ? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents) + : last.toolEvents; const mergedLines = visibleStructuredEvents.length > 0 - ? mergeUniqueToolTraceLines(previousTraces, structuredLines) + ? mergeToolProgressTraceLines( + previousTraces, + last.toolEvents, + structuredLines, + visibleStructuredEvents, + ) : null; const merged: UIMessage = { ...last, - traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines], + traces: mergedLines ?? [...previousTraces, ...lines], content: mergedLines - ? mergedLines.traces[mergedLines.traces.length - 1] + ? mergedLines[mergedLines.length - 1] : lines[lines.length - 1], - toolEvents: visibleStructuredEvents.length - ? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents) - : last.toolEvents, + toolEvents: mergedEvents, activitySegmentId: last.activitySegmentId ?? segmentId, ...turn, }; diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 1113539ba..9d47eb82b 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -514,7 +514,15 @@ "profile": "Profile", "extraHeaders": "Extra headers", "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": { "thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement." diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 768114495..b0a15b096 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -365,7 +365,15 @@ "profile": "Perfil", "extraHeaders": "Cabeceras adicionales", "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": { "selectProvider": "Seleccionar proveedor", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 73c294537..45efeb992 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -365,7 +365,15 @@ "profile": "Profil", "extraHeaders": "En-têtes supplémentaires", "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 d’OpenAI 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 l’API Responses. L’activité de recherche apparaît dans le chat.", + "capabilityXSearch": "Recherche sur X", + "capabilityXSearchHelp": "Permet aux modèles Grok compatibles d’utiliser X Search hébergé par xAI. L’activité de recherche apparaît dans le chat.", + "capabilityOpenAISearch": "Recherche web OpenAI", + "capabilityOpenAISearchHelp": "Permet aux modèles compatibles avec l’API Responses de rechercher sur le web. L’activité de recherche apparaît dans le chat." }, "image": { "selectProvider": "Choisir un fournisseur", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 7e1d99952..b420bae1f 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -365,7 +365,15 @@ "profile": "Profil", "extraHeaders": "Header 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": { "selectProvider": "Pilih penyedia", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 6f13182df..332a16e0b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -365,7 +365,15 @@ "profile": "プロファイル", "extraHeaders": "追加ヘッダー", "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": { "selectProvider": "プロバイダーを選択", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index b2b134cc8..361449be6 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -365,7 +365,15 @@ "profile": "프로필", "extraHeaders": "추가 헤더", "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": { "selectProvider": "제공자 선택", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index c0c75413c..7f499f650 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -514,7 +514,15 @@ "profile": "Perfil", "extraHeaders": "Cabeçalhos adicionais", "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": { "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." diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index d3a5a4773..7586d6aab 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -365,7 +365,15 @@ "profile": "Hồ sơ", "extraHeaders": "Header 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": { "selectProvider": "Chọn nhà cung cấp", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 8faf02a1f..4290de08a 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -514,7 +514,15 @@ "profile": "配置档案", "extraHeaders": "额外请求头", "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": { "thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。" diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 69f487a42..593c79c9f 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -365,7 +365,15 @@ "profile": "設定檔", "extraHeaders": "額外請求標頭", "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": { "selectProvider": "選擇供應商", diff --git a/webui/src/lib/tool-traces.ts b/webui/src/lib/tool-traces.ts index bc87f2155..86ef6b74d 100644 --- a/webui/src/lib/tool-traces.ts +++ b/webui/src/lib/tool-traces.ts @@ -16,7 +16,10 @@ export function formatToolCallTrace(call: unknown): string | null { if (!name) return null; const args = item.function?.arguments ?? item.arguments; 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}()`; } @@ -116,3 +119,24 @@ export function mergeUniqueToolTraceLines( } 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; +} diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index a37ac3e07..147054200 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -1165,12 +1165,13 @@ describe("AgentActivityCluster", () => { id: "search-start", role: "tool", kind: "trace", - content: line, - traces: [line], + content: "web_search()", + traces: ["web_search()"], toolEvents: [{ phase: "start", + call_id: "hosted-search-1", name: "web_search", - arguments: { query: "site:linkedin.com/company Evomap startup" }, + arguments: {}, }], createdAt: 1, }, @@ -1182,6 +1183,7 @@ describe("AgentActivityCluster", () => { traces: [line], toolEvents: [{ phase: "error", + call_id: "hosted-search-1", name: "web_search", arguments: { query: "site:linkedin.com/company Evomap startup" }, error: "Search provider rate limited the request", diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index ed664d427..638ecf0c7 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -580,7 +580,7 @@ describe("webui API helpers", () => { await updateProviderSettings("tok", { provider: "xai_grok", proxy: "http://127.0.0.1:7890", - extraBody: '{"service_tier":"priority"}', + extraBody: '{"tools":[]}', }); expect(fetch).toHaveBeenCalledWith( @@ -590,7 +590,7 @@ describe("webui API helpers", () => { Authorization: "Bearer tok", "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ proxy: "http://127.0.0.1:7890", - extraBody: '{"service_tier":"priority"}', + extraBody: '{"tools":[]}', })), }, }), diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 680fd5d52..954ff7fa8 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -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; + 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; + 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 () => { const base = settingsPayload(); let payload: SettingsPayload = { diff --git a/webui/src/tests/tool-traces.test.ts b/webui/src/tests/tool-traces.test.ts index 45f24f740..73777c6ff 100644 --- a/webui/src/tests/tool-traces.test.ts +++ b/webui/src/tests/tool-traces.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { canonicalToolTrace, + mergeToolProgressTraceLines, mergeUniqueToolTraceLines, } from "@/lib/tool-traces"; @@ -26,4 +27,18 @@ describe("tool trace identity", () => { 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"})']); + }); }); diff --git a/webui/src/tests/trace-activity-model.test.ts b/webui/src/tests/trace-activity-model.test.ts index 67a1885a8..9eb87be3e 100644 --- a/webui/src/tests/trace-activity-model.test.ts +++ b/webui/src/tests/trace-activity-model.test.ts @@ -8,6 +8,10 @@ function describeTrace(line: string, status: GenericToolStatus = "done") { } 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([ ['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"], diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 1206461da..bb26cc689 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -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", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {