mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +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>
|
</details>
|
||||||
|
|
||||||
<a id="responses-state-and-compaction"></a>
|
<a id="responses-state-and-compaction"></a>
|
||||||
@ -695,7 +725,7 @@ Then run:
|
|||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
To opt in to Codex Fast mode, merge this provider setting into `config.json`:
|
Codex Fast mode can be enabled from the WebUI provider settings, or with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -709,9 +739,9 @@ To opt in to Codex Fast mode, merge this provider setting into `config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`priority` is the Responses API request value used by Codex Fast mode. The setting only works
|
The switch sends the Responses API `service_tier: "priority"` value. It only works for models
|
||||||
for models and accounts that support Fast mode; remove `service_tier` to return to standard
|
and accounts that support Fast mode; turn the switch off to return to standard processing.
|
||||||
processing. Fast mode consumes Codex credits at a higher rate. See the
|
Fast mode consumes Codex credits at a higher rate. See the
|
||||||
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
|
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
|
||||||
|
|
||||||
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
||||||
@ -735,6 +765,8 @@ The provider reads xAI's model catalog and includes the server-hosted `x_search`
|
|||||||
tool only when the selected model advertises `supportsBackendSearch`. Models
|
tool only when the selected model advertises `supportsBackendSearch`. Models
|
||||||
without that capability continue normally without hosted X Search. When enabled,
|
without that capability continue normally without hosted X Search. When enabled,
|
||||||
searches run inside xAI's Responses API and citations arrive as inline links.
|
searches run inside xAI's Responses API and citations arrive as inline links.
|
||||||
|
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
|
||||||
|
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
|
||||||
|
|
||||||
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
|
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
|
||||||
public OAuth client and proxy contract used by
|
public OAuth client and proxy contract used by
|
||||||
|
|||||||
@ -262,9 +262,9 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
|
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
|
||||||
|
|
||||||
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
|
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
|
||||||
|
|
||||||
### Custom OpenAI-Compatible Endpoint
|
### Custom OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
@ -528,6 +528,8 @@ When enabled, Grok can search current X posts and return inline source links
|
|||||||
without invoking a local nanobot tool. Credentials are stored under the
|
without invoking a local nanobot tool. Credentials are stored under the
|
||||||
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
||||||
`config.json` and not in Grok Build's credential file.
|
`config.json` and not in Grok Build's credential file.
|
||||||
|
Hosted X Search remains enabled by default and can be disabled with the WebUI
|
||||||
|
switch or `providers.xaiGrok.extraBody.tools: []`.
|
||||||
|
|
||||||
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
|
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
|
||||||
public client contract documented and implemented by
|
public client contract documented and implemented by
|
||||||
|
|||||||
@ -56,6 +56,32 @@ if TYPE_CHECKING:
|
|||||||
# that ``unittest.mock.patch`` can find and replace it.
|
# that ``unittest.mock.patch`` can find and replace it.
|
||||||
AsyncOpenAI: Any = None
|
AsyncOpenAI: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hosted_web_search_type(value: object) -> bool:
|
||||||
|
return isinstance(value, str) and (
|
||||||
|
value == "web_search" or value.startswith("web_search_")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hosted_web_search_tool(tool: object) -> bool:
|
||||||
|
if not isinstance(tool, dict):
|
||||||
|
return False
|
||||||
|
tool_type = cast(dict[object, object], tool).get("type")
|
||||||
|
return _is_hosted_web_search_type(tool_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_named_function_tool(tool: object, name: str) -> bool:
|
||||||
|
"""Return whether a Responses tool is a function with the given name."""
|
||||||
|
if not isinstance(tool, dict):
|
||||||
|
return False
|
||||||
|
record = cast(dict[object, object], tool)
|
||||||
|
if record.get("type") != "function":
|
||||||
|
return False
|
||||||
|
function = record.get("function")
|
||||||
|
if isinstance(function, dict):
|
||||||
|
return cast(dict[object, object], function).get("name") == name
|
||||||
|
return record.get("name") == name
|
||||||
|
|
||||||
_ALLOWED_MSG_KEYS = frozenset({
|
_ALLOWED_MSG_KEYS = frozenset({
|
||||||
"role", "content", "tool_calls", "tool_call_id", "name",
|
"role", "content", "tool_calls", "tool_call_id", "name",
|
||||||
"reasoning_content", "extra_content",
|
"reasoning_content", "extra_content",
|
||||||
@ -469,7 +495,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.extra_headers = extra_headers or {}
|
self.extra_headers = extra_headers or {}
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
self._extra_body = extra_body or {}
|
self._extra_body = dict(extra_body or {})
|
||||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||||
self._extra_query = extra_query or {}
|
self._extra_query = extra_query or {}
|
||||||
self._proxy = proxy or None
|
self._proxy = proxy or None
|
||||||
@ -974,8 +1000,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
provider_responses = spec_name in ("openai", "github_copilot")
|
provider_responses = spec_name in ("openai", "github_copilot")
|
||||||
if not provider_responses and not model_responses:
|
if not provider_responses and not model_responses:
|
||||||
return False
|
return False
|
||||||
if self._api_type == "responses":
|
if self._responses_is_required():
|
||||||
# Explicit configuration means Responses is mandatory; do not
|
# Explicit Responses-only request fields are mandatory; do not
|
||||||
# consult the circuit breaker or fall back to Chat Completions.
|
# consult the circuit breaker or fall back to Chat Completions.
|
||||||
return True
|
return True
|
||||||
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
||||||
@ -994,6 +1020,25 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
||||||
|
|
||||||
|
def _responses_is_required(self) -> bool:
|
||||||
|
return self._api_type == "responses" or self._hosted_web_search_enabled()
|
||||||
|
|
||||||
|
def _hosted_web_search_enabled(self) -> bool:
|
||||||
|
extra_body = getattr(self, "_extra_body", {})
|
||||||
|
configured_tools = extra_body.get("tools")
|
||||||
|
if "tools" in extra_body:
|
||||||
|
return isinstance(configured_tools, list) and any(
|
||||||
|
_is_hosted_web_search_tool(tool)
|
||||||
|
for tool in cast(list[object], configured_tools)
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
self._spec
|
||||||
|
and any(
|
||||||
|
_is_hosted_web_search_type(tool_type)
|
||||||
|
for tool_type in getattr(self._spec, "responses_default_tools", ())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _responses_state_provider(self) -> str:
|
def _responses_state_provider(self) -> str:
|
||||||
spec_name = self._spec.name if self._spec is not None else "custom"
|
spec_name = self._spec.name if self._spec is not None else "custom"
|
||||||
effective_base = self._effective_base or "https://api.openai.com/v1"
|
effective_base = self._effective_base or "https://api.openai.com/v1"
|
||||||
@ -1157,9 +1202,38 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
body["tool_choice"] = tool_choice or "auto"
|
body["tool_choice"] = tool_choice or "auto"
|
||||||
|
|
||||||
extra_body = getattr(self, "_extra_body", {})
|
extra_body = getattr(self, "_extra_body", {})
|
||||||
|
default_tools = getattr(self._spec, "responses_default_tools", ())
|
||||||
|
if "tools" not in extra_body and default_tools:
|
||||||
|
body["tools"] = [
|
||||||
|
*cast(list[object], body.get("tools", [])),
|
||||||
|
*({"type": tool_type} for tool_type in default_tools),
|
||||||
|
]
|
||||||
if extra_body:
|
if extra_body:
|
||||||
body = _merge_responses_extra_body(body, extra_body)
|
body = _merge_responses_extra_body(body, extra_body)
|
||||||
|
|
||||||
|
if self._hosted_web_search_enabled():
|
||||||
|
configured_tools = body.get("tools")
|
||||||
|
if isinstance(configured_tools, list):
|
||||||
|
managed_tools: list[object] = []
|
||||||
|
hosted_search_seen = False
|
||||||
|
for tool in cast(list[object], configured_tools):
|
||||||
|
if _is_named_function_tool(tool, "web_search"):
|
||||||
|
continue
|
||||||
|
if _is_hosted_web_search_tool(tool):
|
||||||
|
if hosted_search_seen:
|
||||||
|
continue
|
||||||
|
hosted_search_seen = True
|
||||||
|
managed_tools.append(tool)
|
||||||
|
body["tools"] = managed_tools
|
||||||
|
if self._spec and self._spec.name == "openai":
|
||||||
|
source_include = "web_search_call.action.sources"
|
||||||
|
configured_include = body.get("include")
|
||||||
|
if isinstance(configured_include, list):
|
||||||
|
if source_include not in configured_include:
|
||||||
|
body["include"] = [*configured_include, source_include]
|
||||||
|
else:
|
||||||
|
body["include"] = [source_include]
|
||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
async def _create_response_with_compaction_fallback(
|
async def _create_response_with_compaction_fallback(
|
||||||
@ -1771,7 +1845,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
if self._api_type == "responses":
|
if self._responses_is_required():
|
||||||
raise
|
raise
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
@ -1867,7 +1941,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
if self._api_type == "responses":
|
if self._responses_is_required():
|
||||||
raise
|
raise
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -89,6 +89,77 @@ def _response_object_list(value: object) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _hosted_web_search_event(
|
||||||
|
event: object,
|
||||||
|
event_type: object,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Map the official web-search output item pair onto normal tool progress."""
|
||||||
|
if event_type not in {"response.output_item.added", "response.output_item.done"}:
|
||||||
|
return None
|
||||||
|
event_object = _response_object(event) or {}
|
||||||
|
item = _response_object(event_object.get("item")) or {}
|
||||||
|
if item.get("type") != "web_search_call":
|
||||||
|
return None
|
||||||
|
call_id = item.get("id") or item.get("call_id") or event_object.get("item_id")
|
||||||
|
if not isinstance(call_id, str) or not call_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action = _response_object(item.get("action")) or {}
|
||||||
|
raw_queries = action.get("queries")
|
||||||
|
queries = (
|
||||||
|
[
|
||||||
|
query.strip()
|
||||||
|
for query in cast(list[object], raw_queries)
|
||||||
|
if isinstance(query, str) and query.strip()
|
||||||
|
][:4]
|
||||||
|
if isinstance(raw_queries, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
query = " · ".join(queries)
|
||||||
|
if not query:
|
||||||
|
query = next(
|
||||||
|
(
|
||||||
|
value.strip()
|
||||||
|
for key in ("query", "pattern", "url")
|
||||||
|
if isinstance((value := action.get(key)), str) and value.strip()
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
arguments = {"query": query[:1000]} if query else {}
|
||||||
|
|
||||||
|
phase = "start" if event_type == "response.output_item.added" else "end"
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
|
if phase == "end":
|
||||||
|
status = item.get("status")
|
||||||
|
result = {"status": status if isinstance(status, str) else "completed"}
|
||||||
|
raw_sources = action.get("sources")
|
||||||
|
if isinstance(raw_sources, list):
|
||||||
|
sources: list[dict[str, str]] = []
|
||||||
|
for raw_source in cast(list[object], raw_sources):
|
||||||
|
source = _response_object(raw_source) or {}
|
||||||
|
url = source.get("url")
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
continue
|
||||||
|
visible_source = {"url": url.strip()[:2048]}
|
||||||
|
title = source.get("title")
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
visible_source["title"] = title.strip()[:300]
|
||||||
|
sources.append(visible_source)
|
||||||
|
if len(sources) == 8:
|
||||||
|
break
|
||||||
|
if sources:
|
||||||
|
result["sources"] = sources
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kind": "hosted_tool",
|
||||||
|
"phase": phase,
|
||||||
|
"call_id": call_id,
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": arguments,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def map_finish_reason(status: str | None) -> str:
|
def map_finish_reason(status: str | None) -> str:
|
||||||
"""Map a Responses API status string to a Chat-Completions-style finish_reason."""
|
"""Map a Responses API status string to a Chat-Completions-style finish_reason."""
|
||||||
return FINISH_REASON_MAP.get(status or "completed", "stop")
|
return FINISH_REASON_MAP.get(status or "completed", "stop")
|
||||||
@ -269,11 +340,14 @@ async def consume_sse_with_reasoning(
|
|||||||
refusal_seen = False
|
refusal_seen = False
|
||||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||||
emitted_refusal_text = ""
|
emitted_refusal_text = ""
|
||||||
|
|
||||||
async for event in iter_sse(response):
|
async for event in iter_sse(response):
|
||||||
if on_response_event:
|
if on_response_event:
|
||||||
await on_response_event(event)
|
await on_response_event(event)
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
|
if on_tool_call_delta and (
|
||||||
|
hosted_event := _hosted_web_search_event(event, event_type)
|
||||||
|
):
|
||||||
|
await on_tool_call_delta(hosted_event)
|
||||||
if event_type == "response.output_item.added":
|
if event_type == "response.output_item.added":
|
||||||
item = _as_json_object(event.get("item")) or {}
|
item = _as_json_object(event.get("item")) or {}
|
||||||
if item.get("type") == "function_call":
|
if item.get("type") == "function_call":
|
||||||
@ -555,10 +629,13 @@ async def consume_sdk_stream(
|
|||||||
refusal_seen = False
|
refusal_seen = False
|
||||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||||
emitted_refusal_text = ""
|
emitted_refusal_text = ""
|
||||||
|
|
||||||
async for raw_event in stream:
|
async for raw_event in stream:
|
||||||
event: Any = raw_event
|
event: Any = raw_event
|
||||||
event_type = getattr(event, "type", None)
|
event_type = getattr(event, "type", None)
|
||||||
|
if on_tool_call_delta and (
|
||||||
|
hosted_event := _hosted_web_search_event(event, event_type)
|
||||||
|
):
|
||||||
|
await on_tool_call_delta(hosted_event)
|
||||||
if event_type == "response.output_item.added":
|
if event_type == "response.output_item.added":
|
||||||
item = getattr(event, "item", None)
|
item = getattr(event, "item", None)
|
||||||
if item and getattr(item, "type", None) == "function_call":
|
if item and getattr(item, "type", None) == "function_call":
|
||||||
|
|||||||
@ -116,6 +116,10 @@ class ProviderSpec:
|
|||||||
# Flash is supported before V4 Pro).
|
# Flash is supported before V4 Pro).
|
||||||
responses_models: tuple[str, ...] = ()
|
responses_models: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
|
||||||
|
# supplies the hosted-tool selection. Values are raw Responses tool types.
|
||||||
|
responses_default_tools: tuple[str, ...] = ()
|
||||||
|
|
||||||
# When the model returns content as a list of {"type":"thinking",...} +
|
# When the model returns content as a list of {"type":"thinking",...} +
|
||||||
# {"type":"text",...} blocks, extract the thinking text into
|
# {"type":"text",...} blocks, extract the thinking text into
|
||||||
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
||||||
@ -479,6 +483,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
default_api_base="https://api.deepseek.com",
|
default_api_base="https://api.deepseek.com",
|
||||||
thinking_style="thinking_type",
|
thinking_style="thinking_type",
|
||||||
responses_models=("deepseek-v4-flash",),
|
responses_models=("deepseek-v4-flash",),
|
||||||
|
responses_default_tools=("web_search",),
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@ -46,6 +46,19 @@ _SENSITIVE_ERROR_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hosted_x_search_tool(value: object) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
return cast(dict[object, object], value).get("type") == "x_search"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_named_x_search_tool(value: object) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
record = cast(dict[object, object], value)
|
||||||
|
return record.get("type") == "function" and record.get("name") == "x_search"
|
||||||
|
|
||||||
|
|
||||||
class XAIGrokProvider(LLMProvider):
|
class XAIGrokProvider(LLMProvider):
|
||||||
"""Call xAI's subscription proxy and expose supported hosted tools."""
|
"""Call xAI's subscription proxy and expose supported hosted tools."""
|
||||||
|
|
||||||
@ -112,13 +125,27 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
stage = "oauth_token"
|
stage = "oauth_token"
|
||||||
try:
|
try:
|
||||||
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
|
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
|
||||||
stage = "model_capabilities"
|
configured_tools = self._extra_body.get("tools")
|
||||||
supports_backend_search = await self._supports_backend_search(token, wire_model)
|
tools_are_explicit = "tools" in self._extra_body
|
||||||
|
configured_hosted_search = (
|
||||||
|
isinstance(configured_tools, list)
|
||||||
|
and any(
|
||||||
|
_is_hosted_x_search_tool(tool)
|
||||||
|
for tool in cast(list[object], configured_tools)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
supports_backend_search = False
|
||||||
|
if not tools_are_explicit:
|
||||||
|
stage = "model_capabilities"
|
||||||
|
supports_backend_search = await self._supports_backend_search(token, wire_model)
|
||||||
converted_tools = convert_tools(tools or [])
|
converted_tools = convert_tools(tools or [])
|
||||||
if supports_backend_search:
|
if isinstance(configured_tools, list):
|
||||||
|
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
|
||||||
|
if supports_backend_search or configured_hosted_search:
|
||||||
converted_tools = [
|
converted_tools = [
|
||||||
tool for tool in converted_tools if tool.get("name") != "x_search"
|
tool for tool in converted_tools if not _is_named_x_search_tool(tool)
|
||||||
]
|
]
|
||||||
|
if supports_backend_search:
|
||||||
converted_tools.append({"type": "x_search"})
|
converted_tools.append({"type": "x_search"})
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
@ -137,7 +164,13 @@ class XAIGrokProvider(LLMProvider):
|
|||||||
"reasoning": _build_reasoning_options(reasoning_effort),
|
"reasoning": _build_reasoning_options(reasoning_effort),
|
||||||
}
|
}
|
||||||
if self._extra_body:
|
if self._extra_body:
|
||||||
body.update(self._extra_body)
|
body.update({
|
||||||
|
key: value
|
||||||
|
for key, value in self._extra_body.items()
|
||||||
|
if key != "tools"
|
||||||
|
})
|
||||||
|
if tools_are_explicit and not isinstance(configured_tools, list):
|
||||||
|
body["tools"] = configured_tools
|
||||||
|
|
||||||
headers = _build_headers(token.access, wire_model)
|
headers = _build_headers(token.access, wire_model)
|
||||||
stage = "xai_request"
|
stage = "xai_request"
|
||||||
|
|||||||
@ -241,6 +241,112 @@ class TestBuildResponsesBodyExtraBody:
|
|||||||
{"type": "web_search"},
|
{"type": "web_search"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_responses_web_search_tool_owns_the_local_function(self) -> None:
|
||||||
|
provider = OpenAICompatProvider(
|
||||||
|
api_key="test-key",
|
||||||
|
default_model="gpt-4o",
|
||||||
|
spec=find_by_name("openai"),
|
||||||
|
extra_body={"tools": [{"type": "web_search"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=_simple_messages(),
|
||||||
|
tools=[
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search with nanobot's configured backend",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model=None,
|
||||||
|
max_tokens=100,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["tools"] == [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
{"type": "web_search"},
|
||||||
|
]
|
||||||
|
assert body["include"] == ["web_search_call.action.sources"]
|
||||||
|
assert provider._should_use_responses_api(None, None) is True
|
||||||
|
|
||||||
|
def test_deepseek_default_search_replaces_the_local_search_function(self) -> None:
|
||||||
|
provider = OpenAICompatProvider(
|
||||||
|
api_key="test-key",
|
||||||
|
default_model="deepseek-v4-flash",
|
||||||
|
spec=find_by_name("deepseek"),
|
||||||
|
)
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=_simple_messages(),
|
||||||
|
tools=[{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search with nanobot's configured backend",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
model=None,
|
||||||
|
max_tokens=100,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["tools"] == [{"type": "web_search"}]
|
||||||
|
assert "include" not in body
|
||||||
|
|
||||||
|
def test_explicit_empty_tools_disables_deepseek_default_search(self) -> None:
|
||||||
|
provider = OpenAICompatProvider(
|
||||||
|
api_key="test-key",
|
||||||
|
default_model="deepseek-v4-flash",
|
||||||
|
spec=find_by_name("deepseek"),
|
||||||
|
extra_body={"tools": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=_simple_messages(),
|
||||||
|
tools=[{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search with nanobot's configured backend",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
model=None,
|
||||||
|
max_tokens=100,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["tools"] == [{
|
||||||
|
"type": "function",
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search with nanobot's configured backend",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
}]
|
||||||
|
|
||||||
def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
|
def test_responses_extra_body_merges_include_without_duplicates(self) -> None:
|
||||||
provider = OpenAICompatProvider(
|
provider = OpenAICompatProvider(
|
||||||
api_key="test-key",
|
api_key="test-key",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -1387,6 +1388,91 @@ class TestConsumeSdkStream:
|
|||||||
assert tool_calls == []
|
assert tool_calls == []
|
||||||
assert finish_reason == "stop"
|
assert finish_reason == "stop"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hosted_web_search_lifecycle_is_streamed_as_tool_progress(self):
|
||||||
|
search_added = SimpleNamespace(
|
||||||
|
type="web_search_call",
|
||||||
|
id="ws_1",
|
||||||
|
status="in_progress",
|
||||||
|
action=SimpleNamespace(type="search"),
|
||||||
|
)
|
||||||
|
search_done = SimpleNamespace(
|
||||||
|
type="web_search_call",
|
||||||
|
id="ws_1",
|
||||||
|
status="completed",
|
||||||
|
action=SimpleNamespace(
|
||||||
|
type="search",
|
||||||
|
queries=["nanobot DeepSeek", "nanobot latest release"],
|
||||||
|
sources=[
|
||||||
|
SimpleNamespace(
|
||||||
|
title="DeepSeek Responses API",
|
||||||
|
url="https://api-docs.deepseek.com/guides/responses_api/",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = SimpleNamespace(status="completed", usage=None, output=[search_done])
|
||||||
|
events = [
|
||||||
|
SimpleNamespace(
|
||||||
|
type="response.output_item.added",
|
||||||
|
output_index=0,
|
||||||
|
item=search_added,
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
type="response.web_search_call.searching",
|
||||||
|
item_id="ws_1",
|
||||||
|
output_index=0,
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
type="response.web_search_call.completed",
|
||||||
|
item_id="ws_1",
|
||||||
|
output_index=0,
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
type="response.output_item.done",
|
||||||
|
output_index=0,
|
||||||
|
item=search_done,
|
||||||
|
),
|
||||||
|
SimpleNamespace(type="response.completed", response=response),
|
||||||
|
]
|
||||||
|
tool_events: list[dict] = []
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
for event in events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
async def on_tool_event(event: dict) -> None:
|
||||||
|
tool_events.append(event)
|
||||||
|
|
||||||
|
await consume_sdk_stream(stream(), on_tool_call_delta=on_tool_event)
|
||||||
|
|
||||||
|
assert tool_events == [
|
||||||
|
{
|
||||||
|
"kind": "hosted_tool",
|
||||||
|
"phase": "start",
|
||||||
|
"call_id": "ws_1",
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": {},
|
||||||
|
"result": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "hosted_tool",
|
||||||
|
"phase": "end",
|
||||||
|
"call_id": "ws_1",
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": {
|
||||||
|
"query": "nanobot DeepSeek · nanobot latest release",
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"status": "completed",
|
||||||
|
"sources": [{
|
||||||
|
"title": "DeepSeek Responses API",
|
||||||
|
"url": "https://api-docs.deepseek.com/guides/responses_api/",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
||||||
refusal = "First and second sentence. Done-only. Terminal suffix."
|
refusal = "First and second sentence. Done-only. Terminal suffix."
|
||||||
|
|||||||
@ -139,6 +139,111 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
|||||||
assert headers["x-grok-model-override"] == "grok-4.5"
|
assert headers["x-grok-model-override"] == "grok-4.5"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_parameterized_x_search_is_preserved_without_catalog_lookup(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
_mock_token(monkeypatch)
|
||||||
|
bodies: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||||
|
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
||||||
|
|
||||||
|
async def fake_request(_url, _headers, body, **_kwargs):
|
||||||
|
bodies.append(body)
|
||||||
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||||
|
unexpected_catalog_lookup,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
|
hosted_tool = {
|
||||||
|
"type": "x_search",
|
||||||
|
"allowed_x_handles": ["nanobot_ai"],
|
||||||
|
"from_date": "2026-01-01",
|
||||||
|
}
|
||||||
|
provider = XAIGrokProvider(extra_body={
|
||||||
|
"parallel_tool_calls": False,
|
||||||
|
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
response = await provider.chat(
|
||||||
|
[{"role": "user", "content": "search"}],
|
||||||
|
tools=[
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "x_search",
|
||||||
|
"description": "A colliding local tool",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.content == "ok"
|
||||||
|
assert bodies[0]["parallel_tool_calls"] is False
|
||||||
|
assert bodies[0]["tools"] == [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
hosted_tool,
|
||||||
|
{"type": "code_interpreter", "container": "auto"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monkeypatch) -> None:
|
||||||
|
_mock_token(monkeypatch)
|
||||||
|
bodies: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||||
|
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
||||||
|
|
||||||
|
async def fake_request(_url, _headers, body, **_kwargs):
|
||||||
|
bodies.append(body)
|
||||||
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||||
|
unexpected_catalog_lookup,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
|
provider = XAIGrokProvider(extra_body={"tools": []})
|
||||||
|
|
||||||
|
response = await provider.chat(
|
||||||
|
[{"role": "user", "content": "hello"}],
|
||||||
|
tools=[{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.content == "ok"
|
||||||
|
assert bodies[0]["tools"] == [{
|
||||||
|
"type": "function",
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object"},
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search(
|
async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_search(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
@ -733,15 +733,19 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = update_provider_settings(
|
payload = update_provider_settings({
|
||||||
{"provider": [provider_name], "proxy": [" http://127.0.0.1:7890 "]}
|
"provider": [provider_name],
|
||||||
)
|
"proxy": [" http://127.0.0.1:7890 "],
|
||||||
|
"extraBody": [json.dumps({"tools": []})],
|
||||||
|
})
|
||||||
|
|
||||||
providers = {row["name"]: row for row in payload["providers"]}
|
providers = {row["name"]: row for row in payload["providers"]}
|
||||||
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
||||||
assert getattr(load_config(config_path).providers, config_attr).proxy == (
|
assert getattr(load_config(config_path).providers, config_attr).proxy == (
|
||||||
"http://127.0.0.1:7890"
|
"http://127.0.0.1:7890"
|
||||||
)
|
)
|
||||||
|
assert providers[provider_name]["extra_body"] == {"tools": []}
|
||||||
|
assert getattr(load_config(config_path).providers, config_attr).extra_body == {"tools": []}
|
||||||
|
|
||||||
cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})
|
cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})
|
||||||
|
|
||||||
|
|||||||
@ -276,6 +276,52 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
|||||||
|
|
||||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
|
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
|
||||||
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
|
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
|
||||||
|
type ProviderRequestOption = {
|
||||||
|
kind: "priority" | "hosted_tool";
|
||||||
|
titleKey: string;
|
||||||
|
title: string;
|
||||||
|
helpKey: string;
|
||||||
|
help: string;
|
||||||
|
toolType?: "web_search" | "x_search";
|
||||||
|
defaultEnabled?: boolean;
|
||||||
|
forceResponses?: boolean;
|
||||||
|
};
|
||||||
|
const PROVIDER_REQUEST_OPTIONS: Partial<Record<string, ProviderRequestOption[]>> = {
|
||||||
|
openai_codex: [{
|
||||||
|
kind: "priority",
|
||||||
|
titleKey: "settings.providers.capabilityFastMode",
|
||||||
|
title: "Fast mode",
|
||||||
|
helpKey: "settings.providers.capabilityFastModeHelp",
|
||||||
|
help: "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
|
||||||
|
}],
|
||||||
|
openai: [{
|
||||||
|
kind: "hosted_tool",
|
||||||
|
titleKey: "settings.providers.capabilityOpenAISearch",
|
||||||
|
title: "OpenAI web search",
|
||||||
|
helpKey: "settings.providers.capabilityOpenAISearchHelp",
|
||||||
|
help: "Allow compatible Responses API models to search the web. Search activity appears in chat.",
|
||||||
|
toolType: "web_search",
|
||||||
|
forceResponses: true,
|
||||||
|
}],
|
||||||
|
deepseek: [{
|
||||||
|
kind: "hosted_tool",
|
||||||
|
titleKey: "settings.providers.capabilityDeepSeekSearch",
|
||||||
|
title: "DeepSeek web search",
|
||||||
|
helpKey: "settings.providers.capabilityDeepSeekSearchHelp",
|
||||||
|
help: "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
|
||||||
|
toolType: "web_search",
|
||||||
|
defaultEnabled: true,
|
||||||
|
}],
|
||||||
|
xai_grok: [{
|
||||||
|
kind: "hosted_tool",
|
||||||
|
titleKey: "settings.providers.capabilityXSearch",
|
||||||
|
title: "X Search",
|
||||||
|
helpKey: "settings.providers.capabilityXSearchHelp",
|
||||||
|
help: "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
|
||||||
|
toolType: "x_search",
|
||||||
|
defaultEnabled: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
|
const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
|
||||||
const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
|
const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
|
||||||
"extra_headers",
|
"extra_headers",
|
||||||
@ -306,6 +352,70 @@ function providerJsonValue(value: Record<string, unknown> | null | undefined): s
|
|||||||
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
|
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProviderExtraBody(value: string): Record<string, unknown> | null {
|
||||||
|
if (!value.trim()) return {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostedSearchTool(tool: unknown, toolType: "web_search" | "x_search"): boolean {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
||||||
|
const configuredType = (tool as Record<string, unknown>).type;
|
||||||
|
if (typeof configuredType !== "string") return false;
|
||||||
|
return configuredType === toolType
|
||||||
|
|| (toolType === "web_search" && configuredType.startsWith("web_search_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHostedSearchTool(value: unknown, toolType: "web_search" | "x_search"): boolean {
|
||||||
|
return Array.isArray(value) && value.some((tool) => isHostedSearchTool(tool, toolType));
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerRequestOptionEnabled(
|
||||||
|
option: ProviderRequestOption,
|
||||||
|
extraBody: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (option.kind === "priority") return extraBody.service_tier === "priority";
|
||||||
|
if (Object.prototype.hasOwnProperty.call(extraBody, "tools")) {
|
||||||
|
return hasHostedSearchTool(extraBody.tools, option.toolType!);
|
||||||
|
}
|
||||||
|
return option.defaultEnabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProviderRequestOption(
|
||||||
|
option: ProviderRequestOption,
|
||||||
|
enabled: boolean,
|
||||||
|
form: ProviderForm,
|
||||||
|
): Partial<ProviderForm> {
|
||||||
|
const extraBody = { ...(parseProviderExtraBody(form.extraBody) ?? {}) };
|
||||||
|
if (option.kind === "priority") {
|
||||||
|
if (enabled) extraBody.service_tier = "priority";
|
||||||
|
else if (extraBody.service_tier === "priority") delete extraBody.service_tier;
|
||||||
|
} else {
|
||||||
|
const toolType = option.toolType!;
|
||||||
|
const tools = Array.isArray(extraBody.tools)
|
||||||
|
? extraBody.tools.filter((tool) => !isHostedSearchTool(tool, toolType))
|
||||||
|
: [];
|
||||||
|
if (enabled) tools.push({ type: toolType });
|
||||||
|
if (tools.length || option.defaultEnabled) {
|
||||||
|
extraBody.tools = tools;
|
||||||
|
} else {
|
||||||
|
delete extraBody.tools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
extraBody: providerJsonValue(extraBody),
|
||||||
|
...(option.forceResponses && enabled
|
||||||
|
? { apiType: "responses" as const }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function providerFormFromRow(
|
function providerFormFromRow(
|
||||||
provider: SettingsPayload["providers"][number],
|
provider: SettingsPayload["providers"][number],
|
||||||
): ProviderForm {
|
): ProviderForm {
|
||||||
@ -3852,6 +3962,62 @@ function ModelAdvancedFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderRequestOptions({
|
||||||
|
providerName,
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
providerName: string;
|
||||||
|
form: ProviderForm;
|
||||||
|
onChange: (value: Partial<ProviderForm>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
|
||||||
|
|
||||||
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const title = tx(option.titleKey, option.title);
|
||||||
|
const Icon = option.kind === "priority" ? Zap : Globe2;
|
||||||
|
const checked = providerRequestOptionEnabled(option, extraBody);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.titleKey}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-4 px-4 py-3",
|
||||||
|
index > 0 && "border-t border-border/45",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground">
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-semibold text-foreground">{title}</p>
|
||||||
|
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||||
|
{tx(option.helpKey, option.help)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToggleButton
|
||||||
|
checked={checked}
|
||||||
|
onChange={(enabled) => onChange(
|
||||||
|
updateProviderRequestOption(option, enabled, form),
|
||||||
|
)}
|
||||||
|
ariaLabel={title}
|
||||||
|
label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderAdvancedOptions({
|
function ProviderAdvancedOptions({
|
||||||
fields,
|
fields,
|
||||||
form,
|
form,
|
||||||
@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{footer ? (
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border/45 pt-3">
|
) : null}
|
||||||
{footer}
|
{footer ? (
|
||||||
</div>
|
<div className="flex items-center justify-end gap-2 border-t border-border/45 py-3">
|
||||||
) : null}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -4320,6 +4486,11 @@ function ProvidersSettings({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProviderRequestOptions
|
||||||
|
providerName={provider.name}
|
||||||
|
form={form}
|
||||||
|
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||||
|
/>
|
||||||
{supportsOauthAdvancedSettings ? (
|
{supportsOauthAdvancedSettings ? (
|
||||||
<ProviderAdvancedOptions
|
<ProviderAdvancedOptions
|
||||||
fields={advancedFields}
|
fields={advancedFields}
|
||||||
@ -4445,6 +4616,11 @@ function ProvidersSettings({
|
|||||||
className="h-9 rounded-full text-[13px]"
|
className="h-9 rounded-full text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<ProviderRequestOptions
|
||||||
|
providerName={provider.name}
|
||||||
|
form={form}
|
||||||
|
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||||
|
/>
|
||||||
<ProviderAdvancedOptions
|
<ProviderAdvancedOptions
|
||||||
fields={advancedFields}
|
fields={advancedFields}
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
canonicalToolTrace,
|
canonicalToolTrace,
|
||||||
mergeToolProgressEvents,
|
mergeToolProgressEvents,
|
||||||
|
mergeToolProgressTraceLines,
|
||||||
mergeUniqueToolTraceLines,
|
mergeUniqueToolTraceLines,
|
||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
|
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||||
@ -56,8 +57,15 @@ function canMergeAdjacentProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
|
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
|
||||||
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
|
||||||
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
|
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
|
||||||
|
const traces = incoming.toolEvents?.length
|
||||||
|
? mergeToolProgressTraceLines(
|
||||||
|
messageTraces(previous),
|
||||||
|
previous.toolEvents,
|
||||||
|
messageTraces(incoming),
|
||||||
|
incoming.toolEvents ?? [],
|
||||||
|
)
|
||||||
|
: mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
||||||
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
|
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
|
||||||
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
|
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function describeTraceLine(
|
|||||||
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
|
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
|
||||||
|
|
||||||
if (/search/i.test(name)) {
|
if (/search/i.test(name)) {
|
||||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed;
|
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args;
|
||||||
return {
|
return {
|
||||||
kind: "search",
|
kind: "search",
|
||||||
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),
|
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export function presentWebSearchAction(
|
|||||||
if (target === "x") {
|
if (target === "x") {
|
||||||
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
|
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
|
||||||
}
|
}
|
||||||
return queryTarget ? `${verb} ${queryTarget}` : verb;
|
return queryTarget ? `${verb} ${queryTarget}` : `${verb} the web`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeWebSearchRun(
|
function mergeWebSearchRun(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useClient } from "@/providers/ClientProvider";
|
|||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import {
|
import {
|
||||||
mergeToolProgressEvents,
|
mergeToolProgressEvents,
|
||||||
mergeUniqueToolTraceLines,
|
mergeToolProgressTraceLines,
|
||||||
normalizeToolProgressEvents,
|
normalizeToolProgressEvents,
|
||||||
toolTraceLinesFromEvents,
|
toolTraceLinesFromEvents,
|
||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
@ -1213,18 +1213,24 @@ export function useNanobotStream(
|
|||||||
: last.content
|
: last.content
|
||||||
? [last.content]
|
? [last.content]
|
||||||
: [];
|
: [];
|
||||||
|
const mergedEvents = visibleStructuredEvents.length > 0
|
||||||
|
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
|
||||||
|
: last.toolEvents;
|
||||||
const mergedLines = visibleStructuredEvents.length > 0
|
const mergedLines = visibleStructuredEvents.length > 0
|
||||||
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
? mergeToolProgressTraceLines(
|
||||||
|
previousTraces,
|
||||||
|
last.toolEvents,
|
||||||
|
structuredLines,
|
||||||
|
visibleStructuredEvents,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const merged: UIMessage = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
traces: mergedLines ?? [...previousTraces, ...lines],
|
||||||
content: mergedLines
|
content: mergedLines
|
||||||
? mergedLines.traces[mergedLines.traces.length - 1]
|
? mergedLines[mergedLines.length - 1]
|
||||||
: lines[lines.length - 1],
|
: lines[lines.length - 1],
|
||||||
toolEvents: visibleStructuredEvents.length
|
toolEvents: mergedEvents,
|
||||||
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
|
|
||||||
: last.toolEvents,
|
|
||||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||||
...turn,
|
...turn,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -514,7 +514,15 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"extraHeaders": "Extra headers",
|
"extraHeaders": "Extra headers",
|
||||||
"extraBody": "Extra body",
|
"extraBody": "Extra body",
|
||||||
"extraQuery": "Extra query"
|
"extraQuery": "Extra query",
|
||||||
|
"capabilityFastMode": "Fast mode",
|
||||||
|
"capabilityFastModeHelp": "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
|
||||||
|
"capabilityDeepSeekSearch": "DeepSeek web search",
|
||||||
|
"capabilityDeepSeekSearchHelp": "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
|
||||||
|
"capabilityXSearch": "X Search",
|
||||||
|
"capabilityXSearchHelp": "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
|
||||||
|
"capabilityOpenAISearch": "OpenAI web search",
|
||||||
|
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"extraHeaders": "Cabeceras adicionales",
|
"extraHeaders": "Cabeceras adicionales",
|
||||||
"extraBody": "Cuerpo adicional",
|
"extraBody": "Cuerpo adicional",
|
||||||
"extraQuery": "Parámetros de consulta adicionales"
|
"extraQuery": "Parámetros de consulta adicionales",
|
||||||
|
"capabilityFastMode": "Modo rápido",
|
||||||
|
"capabilityFastModeHelp": "Usa el nivel de servicio prioritario de OpenAI para responder más rápido. Esto consume créditos más deprisa.",
|
||||||
|
"capabilityDeepSeekSearch": "Búsqueda web de DeepSeek",
|
||||||
|
"capabilityDeepSeekSearchHelp": "Permite que DeepSeek V4 Flash busque en la web mediante Responses API. La actividad de búsqueda aparece en el chat.",
|
||||||
|
"capabilityXSearch": "Búsqueda en X",
|
||||||
|
"capabilityXSearchHelp": "Permite que los modelos Grok compatibles usen X Search alojado por xAI. La actividad de búsqueda aparece en el chat.",
|
||||||
|
"capabilityOpenAISearch": "Búsqueda web de OpenAI",
|
||||||
|
"capabilityOpenAISearchHelp": "Permite que los modelos compatibles con Responses API busquen en la web. La actividad de búsqueda aparece en el chat."
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Seleccionar proveedor",
|
"selectProvider": "Seleccionar proveedor",
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"extraHeaders": "En-têtes supplémentaires",
|
"extraHeaders": "En-têtes supplémentaires",
|
||||||
"extraBody": "Corps supplémentaire",
|
"extraBody": "Corps supplémentaire",
|
||||||
"extraQuery": "Paramètres de requête supplémentaires"
|
"extraQuery": "Paramètres de requête supplémentaires",
|
||||||
|
"capabilityFastMode": "Mode rapide",
|
||||||
|
"capabilityFastModeHelp": "Utilise le niveau de service prioritaire 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": {
|
"image": {
|
||||||
"selectProvider": "Choisir un fournisseur",
|
"selectProvider": "Choisir un fournisseur",
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"extraHeaders": "Header tambahan",
|
"extraHeaders": "Header tambahan",
|
||||||
"extraBody": "Body tambahan",
|
"extraBody": "Body tambahan",
|
||||||
"extraQuery": "Parameter kueri tambahan"
|
"extraQuery": "Parameter kueri tambahan",
|
||||||
|
"capabilityFastMode": "Mode cepat",
|
||||||
|
"capabilityFastModeHelp": "Gunakan tingkat layanan prioritas OpenAI agar respons lebih cepat. Kredit akan terpakai lebih cepat.",
|
||||||
|
"capabilityDeepSeekSearch": "Pencarian web DeepSeek",
|
||||||
|
"capabilityDeepSeekSearchHelp": "Izinkan DeepSeek V4 Flash mencari di web melalui Responses API. Aktivitas pencarian ditampilkan di chat.",
|
||||||
|
"capabilityXSearch": "Pencarian X",
|
||||||
|
"capabilityXSearchHelp": "Izinkan model Grok yang didukung menggunakan X Search yang dihosting xAI. Aktivitas pencarian ditampilkan di chat.",
|
||||||
|
"capabilityOpenAISearch": "Pencarian web OpenAI",
|
||||||
|
"capabilityOpenAISearchHelp": "Izinkan model yang kompatibel dengan Responses API mencari di web. Aktivitas pencarian ditampilkan di chat."
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Pilih penyedia",
|
"selectProvider": "Pilih penyedia",
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "プロファイル",
|
"profile": "プロファイル",
|
||||||
"extraHeaders": "追加ヘッダー",
|
"extraHeaders": "追加ヘッダー",
|
||||||
"extraBody": "追加ボディ",
|
"extraBody": "追加ボディ",
|
||||||
"extraQuery": "追加クエリ"
|
"extraQuery": "追加クエリ",
|
||||||
|
"capabilityFastMode": "高速モード",
|
||||||
|
"capabilityFastModeHelp": "OpenAI の優先サービス階層を使用して応答を高速化します。クレジットの消費も速くなります。",
|
||||||
|
"capabilityDeepSeekSearch": "DeepSeek ウェブ検索",
|
||||||
|
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash が Responses API 経由でウェブを検索できるようにします。検索状況はチャットに表示されます。",
|
||||||
|
"capabilityXSearch": "X 検索",
|
||||||
|
"capabilityXSearchHelp": "対応する Grok モデルが xAI ホストの X Search を使用できるようにします。検索状況はチャットに表示されます。",
|
||||||
|
"capabilityOpenAISearch": "OpenAI ウェブ検索",
|
||||||
|
"capabilityOpenAISearchHelp": "Responses API 対応モデルがウェブを検索できるようにします。検索状況はチャットに表示されます。"
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "プロバイダーを選択",
|
"selectProvider": "プロバイダーを選択",
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "프로필",
|
"profile": "프로필",
|
||||||
"extraHeaders": "추가 헤더",
|
"extraHeaders": "추가 헤더",
|
||||||
"extraBody": "추가 본문",
|
"extraBody": "추가 본문",
|
||||||
"extraQuery": "추가 쿼리"
|
"extraQuery": "추가 쿼리",
|
||||||
|
"capabilityFastMode": "고속 모드",
|
||||||
|
"capabilityFastModeHelp": "OpenAI 우선 서비스 등급을 사용해 더 빠르게 응답합니다. 크레딧도 더 빠르게 소모됩니다.",
|
||||||
|
"capabilityDeepSeekSearch": "DeepSeek 웹 검색",
|
||||||
|
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash가 Responses API를 통해 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
|
||||||
|
"capabilityXSearch": "X 검색",
|
||||||
|
"capabilityXSearchHelp": "지원되는 Grok 모델이 xAI 호스팅 X Search를 사용하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
|
||||||
|
"capabilityOpenAISearch": "OpenAI 웹 검색",
|
||||||
|
"capabilityOpenAISearchHelp": "Responses API 호환 모델이 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다."
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "제공자 선택",
|
"selectProvider": "제공자 선택",
|
||||||
|
|||||||
@ -514,7 +514,15 @@
|
|||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"extraHeaders": "Cabeçalhos adicionais",
|
"extraHeaders": "Cabeçalhos adicionais",
|
||||||
"extraBody": "Corpo adicional",
|
"extraBody": "Corpo adicional",
|
||||||
"extraQuery": "Parâmetros de consulta adicionais"
|
"extraQuery": "Parâmetros de consulta adicionais",
|
||||||
|
"capabilityFastMode": "Modo rápido",
|
||||||
|
"capabilityFastModeHelp": "Usa o nível de serviço prioritário da OpenAI para respostas mais rápidas. Isso consome créditos mais rapidamente.",
|
||||||
|
"capabilityDeepSeekSearch": "Pesquisa web do DeepSeek",
|
||||||
|
"capabilityDeepSeekSearchHelp": "Permite que o DeepSeek V4 Flash pesquise na web pela Responses API. A atividade de pesquisa aparece no chat.",
|
||||||
|
"capabilityXSearch": "Pesquisa no X",
|
||||||
|
"capabilityXSearchHelp": "Permite que modelos Grok compatíveis usem o X Search hospedado pela xAI. A atividade de pesquisa aparece no chat.",
|
||||||
|
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
|
||||||
|
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
|
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "Hồ sơ",
|
"profile": "Hồ sơ",
|
||||||
"extraHeaders": "Header bổ sung",
|
"extraHeaders": "Header bổ sung",
|
||||||
"extraBody": "Body bổ sung",
|
"extraBody": "Body bổ sung",
|
||||||
"extraQuery": "Tham số truy vấn bổ sung"
|
"extraQuery": "Tham số truy vấn bổ sung",
|
||||||
|
"capabilityFastMode": "Chế độ nhanh",
|
||||||
|
"capabilityFastModeHelp": "Dùng tầng dịch vụ ưu tiên của OpenAI để phản hồi nhanh hơn. Tín dụng cũng sẽ được tiêu thụ nhanh hơn.",
|
||||||
|
"capabilityDeepSeekSearch": "Tìm kiếm web DeepSeek",
|
||||||
|
"capabilityDeepSeekSearchHelp": "Cho phép DeepSeek V4 Flash tìm kiếm trên web qua Responses API. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
|
||||||
|
"capabilityXSearch": "Tìm kiếm X",
|
||||||
|
"capabilityXSearchHelp": "Cho phép các mô hình Grok được hỗ trợ dùng X Search do xAI lưu trữ. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
|
||||||
|
"capabilityOpenAISearch": "Tìm kiếm web OpenAI",
|
||||||
|
"capabilityOpenAISearchHelp": "Cho phép các mô hình tương thích với Responses API tìm kiếm trên web. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện."
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "Chọn nhà cung cấp",
|
"selectProvider": "Chọn nhà cung cấp",
|
||||||
|
|||||||
@ -514,7 +514,15 @@
|
|||||||
"profile": "配置档案",
|
"profile": "配置档案",
|
||||||
"extraHeaders": "额外请求头",
|
"extraHeaders": "额外请求头",
|
||||||
"extraBody": "额外请求体",
|
"extraBody": "额外请求体",
|
||||||
"extraQuery": "额外查询参数"
|
"extraQuery": "额外查询参数",
|
||||||
|
"capabilityFastMode": "Fast 模式",
|
||||||
|
"capabilityFastModeHelp": "通过 OpenAI 优先服务层获取更快响应,会更快消耗额度。",
|
||||||
|
"capabilityDeepSeekSearch": "DeepSeek 联网搜索",
|
||||||
|
"capabilityDeepSeekSearchHelp": "允许 DeepSeek V4 Flash 通过 Responses API 搜索网络,并在对话中展示搜索过程。",
|
||||||
|
"capabilityXSearch": "X 搜索",
|
||||||
|
"capabilityXSearchHelp": "允许受支持的 Grok 模型使用 xAI 托管的 X Search,并在对话中展示搜索过程。",
|
||||||
|
"capabilityOpenAISearch": "OpenAI 联网搜索",
|
||||||
|
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||||
|
|||||||
@ -365,7 +365,15 @@
|
|||||||
"profile": "設定檔",
|
"profile": "設定檔",
|
||||||
"extraHeaders": "額外請求標頭",
|
"extraHeaders": "額外請求標頭",
|
||||||
"extraBody": "額外請求本文",
|
"extraBody": "額外請求本文",
|
||||||
"extraQuery": "額外查詢參數"
|
"extraQuery": "額外查詢參數",
|
||||||
|
"capabilityFastMode": "Fast 模式",
|
||||||
|
"capabilityFastModeHelp": "透過 OpenAI 優先服務層取得更快回應,也會更快消耗額度。",
|
||||||
|
"capabilityDeepSeekSearch": "DeepSeek 網路搜尋",
|
||||||
|
"capabilityDeepSeekSearchHelp": "允許 DeepSeek V4 Flash 透過 Responses API 搜尋網路,並在對話中顯示搜尋過程。",
|
||||||
|
"capabilityXSearch": "X 搜尋",
|
||||||
|
"capabilityXSearchHelp": "允許支援的 Grok 模型使用 xAI 託管的 X Search,並在對話中顯示搜尋過程。",
|
||||||
|
"capabilityOpenAISearch": "OpenAI 網路搜尋",
|
||||||
|
"capabilityOpenAISearchHelp": "允許相容 Responses API 的模型搜尋網路,並在對話中顯示搜尋過程。"
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"selectProvider": "選擇供應商",
|
"selectProvider": "選擇供應商",
|
||||||
|
|||||||
@ -16,7 +16,10 @@ export function formatToolCallTrace(call: unknown): string | null {
|
|||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const args = item.function?.arguments ?? item.arguments;
|
const args = item.function?.arguments ?? item.arguments;
|
||||||
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
|
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
|
||||||
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`;
|
if (args && typeof args === "object") {
|
||||||
|
const serialized = JSON.stringify(args);
|
||||||
|
return serialized === "{}" || serialized === "[]" ? `${name}()` : `${name}(${serialized})`;
|
||||||
|
}
|
||||||
return `${name}()`;
|
return `${name}()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,3 +119,24 @@ export function mergeUniqueToolTraceLines(
|
|||||||
}
|
}
|
||||||
return { traces, added };
|
return { traces, added };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeToolProgressTraceLines(
|
||||||
|
previousTraces: string[],
|
||||||
|
previousEvents: ToolProgressEvent[] | undefined,
|
||||||
|
incomingTraces: string[],
|
||||||
|
incomingEvents: ToolProgressEvent[],
|
||||||
|
): string[] {
|
||||||
|
const mergedEvents = mergeToolProgressEvents(previousEvents, incomingEvents);
|
||||||
|
const candidates = mergeUniqueToolTraceLines(previousTraces, incomingTraces).traces;
|
||||||
|
const eventTraceKeys = new Set([
|
||||||
|
...toolTraceLinesFromEvents(previousEvents),
|
||||||
|
...toolTraceLinesFromEvents(incomingEvents),
|
||||||
|
].map(canonicalToolTrace));
|
||||||
|
const nonEventTraces = candidates.filter(
|
||||||
|
(line) => !eventTraceKeys.has(canonicalToolTrace(line)),
|
||||||
|
);
|
||||||
|
return mergeUniqueToolTraceLines(
|
||||||
|
nonEventTraces,
|
||||||
|
toolTraceLinesFromEvents(mergedEvents),
|
||||||
|
).traces;
|
||||||
|
}
|
||||||
|
|||||||
@ -1165,12 +1165,13 @@ describe("AgentActivityCluster", () => {
|
|||||||
id: "search-start",
|
id: "search-start",
|
||||||
role: "tool",
|
role: "tool",
|
||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: line,
|
content: "web_search()",
|
||||||
traces: [line],
|
traces: ["web_search()"],
|
||||||
toolEvents: [{
|
toolEvents: [{
|
||||||
phase: "start",
|
phase: "start",
|
||||||
|
call_id: "hosted-search-1",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
arguments: { query: "site:linkedin.com/company Evomap startup" },
|
arguments: {},
|
||||||
}],
|
}],
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
},
|
},
|
||||||
@ -1182,6 +1183,7 @@ describe("AgentActivityCluster", () => {
|
|||||||
traces: [line],
|
traces: [line],
|
||||||
toolEvents: [{
|
toolEvents: [{
|
||||||
phase: "error",
|
phase: "error",
|
||||||
|
call_id: "hosted-search-1",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
arguments: { query: "site:linkedin.com/company Evomap startup" },
|
arguments: { query: "site:linkedin.com/company Evomap startup" },
|
||||||
error: "Search provider rate limited the request",
|
error: "Search provider rate limited the request",
|
||||||
|
|||||||
@ -580,7 +580,7 @@ describe("webui API helpers", () => {
|
|||||||
await updateProviderSettings("tok", {
|
await updateProviderSettings("tok", {
|
||||||
provider: "xai_grok",
|
provider: "xai_grok",
|
||||||
proxy: "http://127.0.0.1:7890",
|
proxy: "http://127.0.0.1:7890",
|
||||||
extraBody: '{"service_tier":"priority"}',
|
extraBody: '{"tools":[]}',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
@ -590,7 +590,7 @@ describe("webui API helpers", () => {
|
|||||||
Authorization: "Bearer tok",
|
Authorization: "Bearer tok",
|
||||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||||
proxy: "http://127.0.0.1:7890",
|
proxy: "http://127.0.0.1:7890",
|
||||||
extraBody: '{"service_tier":"priority"}',
|
extraBody: '{"tools":[]}',
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -3101,6 +3101,200 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps provider request switches to raw extraBody fields", async () => {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const providers: SettingsPayload["providers"] = [
|
||||||
|
{
|
||||||
|
name: "xai_grok",
|
||||||
|
label: "xAI Grok",
|
||||||
|
configured: true,
|
||||||
|
auth_type: "oauth",
|
||||||
|
api_key_required: false,
|
||||||
|
oauth_account: "grok@example.com",
|
||||||
|
oauth_login_supported: true,
|
||||||
|
advanced_fields: ["extra_body", "proxy"],
|
||||||
|
extra_body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai_codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
configured: true,
|
||||||
|
auth_type: "oauth",
|
||||||
|
api_key_required: false,
|
||||||
|
oauth_account: "codex@example.com",
|
||||||
|
oauth_login_supported: true,
|
||||||
|
advanced_fields: ["extra_body", "proxy"],
|
||||||
|
extra_body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
configured: true,
|
||||||
|
api_key_required: true,
|
||||||
|
api_key_hint: "deep••••test",
|
||||||
|
api_base: "https://api.deepseek.com",
|
||||||
|
advanced_fields: ["extra_body"],
|
||||||
|
extra_body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai",
|
||||||
|
label: "OpenAI",
|
||||||
|
configured: true,
|
||||||
|
api_key_required: true,
|
||||||
|
api_key_hint: "sk-••••test",
|
||||||
|
api_base: "https://api.openai.com/v1",
|
||||||
|
api_type: "auto",
|
||||||
|
advanced_fields: ["api_type", "extra_body"],
|
||||||
|
extra_body: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const payload: SettingsPayload = { ...base, providers };
|
||||||
|
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
|
||||||
|
const [input] = args;
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
|
||||||
|
const xSearch = screen.getByRole("switch", { name: "X Search" });
|
||||||
|
expect(xSearch).toHaveAttribute("aria-checked", "true");
|
||||||
|
fireEvent.click(xSearch);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/update?provider=xai_grok",
|
||||||
|
expect.anything(),
|
||||||
|
));
|
||||||
|
await waitFor(() => expect(
|
||||||
|
screen.getByRole("button", { name: "Save provider" }),
|
||||||
|
).toBeEnabled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/update?provider=openai_codex",
|
||||||
|
expect.anything(),
|
||||||
|
));
|
||||||
|
await waitFor(() => expect(
|
||||||
|
screen.getByRole("button", { name: "Save provider" }),
|
||||||
|
).toBeEnabled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
|
||||||
|
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
|
||||||
|
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
|
||||||
|
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
|
||||||
|
fireEvent.click(deepSeekSearch);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||||
|
await waitFor(() => expect(
|
||||||
|
screen.queryByRole("switch", { name: "DeepSeek web search" }),
|
||||||
|
).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||||
|
await waitFor(() => expect(
|
||||||
|
screen.queryByRole("switch", { name: "OpenAI web search" }),
|
||||||
|
).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const requestUpdates = fetchMock.mock.calls
|
||||||
|
.filter(([input]) => String(input).startsWith("/api/settings/provider/update?"))
|
||||||
|
.map(([input, init]) => {
|
||||||
|
const provider = new URLSearchParams(String(input).split("?")[1]).get("provider");
|
||||||
|
const headers = init?.headers as Record<string, string>;
|
||||||
|
const values = JSON.parse(decodeURIComponent(
|
||||||
|
headers["X-Nanobot-Provider-Values"],
|
||||||
|
)) as { apiType?: string; extraBody?: string };
|
||||||
|
return [provider, {
|
||||||
|
...(values.apiType ? { apiType: values.apiType } : {}),
|
||||||
|
extraBody: JSON.parse(values.extraBody ?? "{}"),
|
||||||
|
}] as const;
|
||||||
|
});
|
||||||
|
expect(requestUpdates).toEqual([
|
||||||
|
["xai_grok", { extraBody: { tools: [] } }],
|
||||||
|
["openai_codex", { extraBody: { service_tier: "priority" } }],
|
||||||
|
["deepseek", { extraBody: { tools: [] } }],
|
||||||
|
["openai", {
|
||||||
|
apiType: "responses",
|
||||||
|
extraBody: { tools: [{ type: "web_search" }] },
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...base,
|
||||||
|
providers: [{
|
||||||
|
name: "openai",
|
||||||
|
label: "OpenAI",
|
||||||
|
configured: true,
|
||||||
|
api_key_required: true,
|
||||||
|
api_key_hint: "sk-••••test",
|
||||||
|
api_base: "https://api.openai.com/v1",
|
||||||
|
api_type: "auto",
|
||||||
|
advanced_fields: ["api_type", "extra_body"],
|
||||||
|
extra_body: {
|
||||||
|
metadata: { owner: "legacy-config" },
|
||||||
|
tools: [
|
||||||
|
{ type: "web_search_preview", search_context_size: "medium" },
|
||||||
|
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
|
||||||
|
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
|
||||||
|
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
|
||||||
|
fireEvent.click(searchSwitch);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const updateCall = fetchMock.mock.calls.find(
|
||||||
|
([input]) => String(input).startsWith("/api/settings/provider/update?"),
|
||||||
|
);
|
||||||
|
expect(updateCall).toBeTruthy();
|
||||||
|
const headers = updateCall?.[1]?.headers as Record<string, string>;
|
||||||
|
const values = JSON.parse(decodeURIComponent(
|
||||||
|
headers["X-Nanobot-Provider-Values"],
|
||||||
|
)) as { extraBody: string };
|
||||||
|
expect(JSON.parse(values.extraBody)).toEqual({
|
||||||
|
metadata: { owner: "legacy-config" },
|
||||||
|
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a custom provider with folded advanced request settings", async () => {
|
it("creates a custom provider with folded advanced request settings", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
let payload: SettingsPayload = {
|
let payload: SettingsPayload = {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
canonicalToolTrace,
|
canonicalToolTrace,
|
||||||
|
mergeToolProgressTraceLines,
|
||||||
mergeUniqueToolTraceLines,
|
mergeUniqueToolTraceLines,
|
||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
|
|
||||||
@ -26,4 +27,18 @@ describe("tool trace identity", () => {
|
|||||||
added: true,
|
added: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces an empty streaming placeholder when hosted search arguments arrive", () => {
|
||||||
|
expect(mergeToolProgressTraceLines(
|
||||||
|
["web_search()"],
|
||||||
|
[{ phase: "start", call_id: "ws-1", name: "web_search", arguments: {} }],
|
||||||
|
['web_search({"query":"nanobot news"})'],
|
||||||
|
[{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "ws-1",
|
||||||
|
name: "web_search",
|
||||||
|
arguments: { query: "nanobot news" },
|
||||||
|
}],
|
||||||
|
)).toEqual(['web_search({"query":"nanobot news"})']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,10 @@ function describeTrace(line: string, status: GenericToolStatus = "done") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("trace activity semantics", () => {
|
describe("trace activity semantics", () => {
|
||||||
|
it("uses readable copy when a hosted search query is unavailable", () => {
|
||||||
|
expect(describeTrace("web_search()").label).toBe("Searched the web");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
|
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
|
||||||
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],
|
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],
|
||||||
|
|||||||
@ -610,6 +610,51 @@ describe("useNanobotStream", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces a hosted search placeholder when its query arrives", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-hosted-search", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-hosted-search", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-hosted-search",
|
||||||
|
text: "web_search()",
|
||||||
|
kind: "tool_hint",
|
||||||
|
tool_events: [{
|
||||||
|
phase: "start",
|
||||||
|
call_id: "ws-1",
|
||||||
|
name: "web_search",
|
||||||
|
arguments: {},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fake.emit("chat-hosted-search", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-hosted-search",
|
||||||
|
text: "",
|
||||||
|
kind: "progress",
|
||||||
|
tool_events: [{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "ws-1",
|
||||||
|
name: "web_search",
|
||||||
|
arguments: { query: "nanobot news" },
|
||||||
|
result: { status: "completed" },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(1);
|
||||||
|
expect(result.current.messages[0].traces).toEqual([
|
||||||
|
'web_search({"query":"nanobot news"})',
|
||||||
|
]);
|
||||||
|
expect(result.current.messages[0].toolEvents).toMatchObject([{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "ws-1",
|
||||||
|
arguments: { query: "nanobot news" },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps phase updates when a tool event trace line is deduped", () => {
|
it("keeps phase updates when a tool event trace line is deduped", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user