mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +00:00
feat: add provider-native request switches (#5254)
This commit is contained in:
parent
5a1ab44baa
commit
67805f5db8
@ -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>
|
||||
|
||||
<a id="responses-state-and-compaction"></a>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -241,6 +241,112 @@ class TestBuildResponsesBodyExtraBody:
|
||||
{"type": "web_search"},
|
||||
]
|
||||
|
||||
def test_responses_web_search_tool_owns_the_local_function(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="gpt-4o",
|
||||
spec=find_by_name("openai"),
|
||||
extra_body={"tools": [{"type": "web_search"}]},
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
assert body["include"] == ["web_search_call.action.sources"]
|
||||
assert provider._should_use_responses_api(None, None) is True
|
||||
|
||||
def test_deepseek_default_search_replaces_the_local_search_function(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="deepseek-v4-flash",
|
||||
spec=find_by_name("deepseek"),
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [{"type": "web_search"}]
|
||||
assert "include" not in body
|
||||
|
||||
def test_explicit_empty_tools_disables_deepseek_default_search(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="deepseek-v4-flash",
|
||||
spec=find_by_name("deepseek"),
|
||||
extra_body={"tools": []},
|
||||
)
|
||||
|
||||
body = provider._build_responses_body(
|
||||
messages=_simple_messages(),
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
model=None,
|
||||
max_tokens=100,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert body["tools"] == [{
|
||||
"type": "function",
|
||||
"name": "web_search",
|
||||
"description": "Search with nanobot's configured backend",
|
||||
"parameters": {"type": "object"},
|
||||
}]
|
||||
|
||||
def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -1387,6 +1388,91 @@ class TestConsumeSdkStream:
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_web_search_lifecycle_is_streamed_as_tool_progress(self):
|
||||
search_added = SimpleNamespace(
|
||||
type="web_search_call",
|
||||
id="ws_1",
|
||||
status="in_progress",
|
||||
action=SimpleNamespace(type="search"),
|
||||
)
|
||||
search_done = SimpleNamespace(
|
||||
type="web_search_call",
|
||||
id="ws_1",
|
||||
status="completed",
|
||||
action=SimpleNamespace(
|
||||
type="search",
|
||||
queries=["nanobot DeepSeek", "nanobot latest release"],
|
||||
sources=[
|
||||
SimpleNamespace(
|
||||
title="DeepSeek Responses API",
|
||||
url="https://api-docs.deepseek.com/guides/responses_api/",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(status="completed", usage=None, output=[search_done])
|
||||
events = [
|
||||
SimpleNamespace(
|
||||
type="response.output_item.added",
|
||||
output_index=0,
|
||||
item=search_added,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.web_search_call.searching",
|
||||
item_id="ws_1",
|
||||
output_index=0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.web_search_call.completed",
|
||||
item_id="ws_1",
|
||||
output_index=0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="response.output_item.done",
|
||||
output_index=0,
|
||||
item=search_done,
|
||||
),
|
||||
SimpleNamespace(type="response.completed", response=response),
|
||||
]
|
||||
tool_events: list[dict] = []
|
||||
|
||||
async def stream():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
async def on_tool_event(event: dict) -> None:
|
||||
tool_events.append(event)
|
||||
|
||||
await consume_sdk_stream(stream(), on_tool_call_delta=on_tool_event)
|
||||
|
||||
assert tool_events == [
|
||||
{
|
||||
"kind": "hosted_tool",
|
||||
"phase": "start",
|
||||
"call_id": "ws_1",
|
||||
"name": "web_search",
|
||||
"arguments": {},
|
||||
"result": None,
|
||||
},
|
||||
{
|
||||
"kind": "hosted_tool",
|
||||
"phase": "end",
|
||||
"call_id": "ws_1",
|
||||
"name": "web_search",
|
||||
"arguments": {
|
||||
"query": "nanobot DeepSeek · nanobot latest release",
|
||||
},
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"sources": [{
|
||||
"title": "DeepSeek Responses API",
|
||||
"url": "https://api-docs.deepseek.com/guides/responses_api/",
|
||||
}],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
||||
refusal = "First and second sentence. Done-only. Terminal suffix."
|
||||
|
||||
@ -139,6 +139,111 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
||||
assert headers["x-grok-model-override"] == "grok-4.5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_parameterized_x_search_is_preserved_without_catalog_lookup(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
hosted_tool = {
|
||||
"type": "x_search",
|
||||
"allowed_x_handles": ["nanobot_ai"],
|
||||
"from_date": "2026-01-01",
|
||||
}
|
||||
provider = XAIGrokProvider(extra_body={
|
||||
"parallel_tool_calls": False,
|
||||
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||
})
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "search"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "x_search",
|
||||
"description": "A colliding local tool",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["parallel_tool_calls"] is False
|
||||
assert bodies[0]["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
hosted_tool,
|
||||
{"type": "code_interpreter", "container": "auto"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monkeypatch) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
provider = XAIGrokProvider(extra_body={"tools": []})
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["tools"] == [{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search(
|
||||
monkeypatch,
|
||||
|
||||
@ -733,15 +733,19 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
|
||||
},
|
||||
)
|
||||
|
||||
payload = update_provider_settings(
|
||||
{"provider": [provider_name], "proxy": [" http://127.0.0.1:7890 "]}
|
||||
)
|
||||
payload = update_provider_settings({
|
||||
"provider": [provider_name],
|
||||
"proxy": [" http://127.0.0.1:7890 "],
|
||||
"extraBody": [json.dumps({"tools": []})],
|
||||
})
|
||||
|
||||
providers = {row["name"]: row for row in payload["providers"]}
|
||||
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
||||
assert getattr(load_config(config_path).providers, config_attr).proxy == (
|
||||
"http://127.0.0.1:7890"
|
||||
)
|
||||
assert providers[provider_name]["extra_body"] == {"tools": []}
|
||||
assert getattr(load_config(config_path).providers, config_attr).extra_body == {"tools": []}
|
||||
|
||||
cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})
|
||||
|
||||
|
||||
@ -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<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_ADVANCED_FIELDS: ProviderAdvancedField[] = [
|
||||
"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) : "";
|
||||
}
|
||||
|
||||
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(
|
||||
provider: SettingsPayload["providers"][number],
|
||||
): 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({
|
||||
fields,
|
||||
form,
|
||||
@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border/45 pt-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border/45 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -4320,6 +4486,11 @@ function ProvidersSettings({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderRequestOptions
|
||||
providerName={provider.name}
|
||||
form={form}
|
||||
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||
/>
|
||||
{supportsOauthAdvancedSettings ? (
|
||||
<ProviderAdvancedOptions
|
||||
fields={advancedFields}
|
||||
@ -4445,6 +4616,11 @@ function ProvidersSettings({
|
||||
className="h-9 rounded-full text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
<ProviderRequestOptions
|
||||
providerName={provider.name}
|
||||
form={form}
|
||||
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||
/>
|
||||
<ProviderAdvancedOptions
|
||||
fields={advancedFields}
|
||||
form={form}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
canonicalToolTrace,
|
||||
mergeToolProgressEvents,
|
||||
mergeToolProgressTraceLines,
|
||||
mergeUniqueToolTraceLines,
|
||||
} from "@/lib/tool-traces";
|
||||
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
@ -56,8 +57,15 @@ function canMergeAdjacentProgress(
|
||||
}
|
||||
|
||||
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
|
||||
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
||||
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 media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ export function describeTraceLine(
|
||||
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
|
||||
|
||||
if (/search/i.test(name)) {
|
||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed;
|
||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args;
|
||||
return {
|
||||
kind: "search",
|
||||
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),
|
||||
|
||||
@ -105,7 +105,7 @@ export function presentWebSearchAction(
|
||||
if (target === "x") {
|
||||
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
|
||||
}
|
||||
return queryTarget ? `${verb} ${queryTarget}` : verb;
|
||||
return queryTarget ? `${verb} ${queryTarget}` : `${verb} the web`;
|
||||
}
|
||||
|
||||
function mergeWebSearchRun(
|
||||
|
||||
@ -4,7 +4,7 @@ import { useClient } from "@/providers/ClientProvider";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import {
|
||||
mergeToolProgressEvents,
|
||||
mergeUniqueToolTraceLines,
|
||||
mergeToolProgressTraceLines,
|
||||
normalizeToolProgressEvents,
|
||||
toolTraceLinesFromEvents,
|
||||
} from "@/lib/tool-traces";
|
||||
@ -1213,18 +1213,24 @@ export function useNanobotStream(
|
||||
: last.content
|
||||
? [last.content]
|
||||
: [];
|
||||
const mergedEvents = visibleStructuredEvents.length > 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,
|
||||
};
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "プロバイダーを選択",
|
||||
|
||||
@ -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": "제공자 선택",
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
|
||||
@ -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": "選擇供應商",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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":[]}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@ -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 () => {
|
||||
const base = settingsPayload();
|
||||
let payload: SettingsPayload = {
|
||||
|
||||
@ -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"})']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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), {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user