diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index db9375d0..7f7346bf 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -57,7 +57,11 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the }, "websearch": { "enabled": true, - "provider": "exa-hosted-mcp" + "provider": "exa-hosted-mcp", + "native": { + "web": false, + "x": true + } }, "mcp": { "gh_grep": { diff --git a/_docs/config/websearch.mdx b/_docs/config/websearch.mdx index e048addb..c6c67638 100644 --- a/_docs/config/websearch.mdx +++ b/_docs/config/websearch.mdx @@ -1,51 +1,96 @@ --- -title: Websearch -description: Configure the built-in websearch tool and search provider. +title: websearch +summary: Local search backends and provider-native search tools --- -# Web Search +Configure web search for agents. Crabcode supports two complementary paths: -crabcode exposes a built-in `websearch` tool by default. Agents use `websearch` for discovery and `webfetch` when they already know the URL. The default provider is Exa's hosted MCP service, which does not require an API key. +1. **Local adapters** (`websearch.provider`) — crabcode runs a `websearch` tool against Exa, Tavily, Brave, Ollama Cloud, etc. +2. **Native tools** (`websearch.native`) — the active LLM provider executes search server-side (`web_search`, `x_search`, …). These are normal aisdk tools composed into the tools list. -```jsonc title="crabcode.jsonc" +```jsonc { "websearch": { "enabled": true, - "provider": "exa-hosted-mcp" - }, - "permission": { - "websearch": "allow" + "provider": "exa-hosted-mcp", + "apiKey": null, + "endpoint": null, + "native": { + "web": false, + "x": true + } } } ``` -Use `websearch: false` to disable the tool. +`websearch: false` (or `"enabled": false`) disables both local and native search. -## Providers +## Local vs native -| Provider | API key config | -| --- | --- | -| `exa-hosted-mcp` | Optional `apiKey`; works without one | -| `firecrawl-hosted-mcp` | Optional `apiKey`; works without one (Firecrawl Keyless) | -| `exa` | `apiKey: "{env:EXA_API_KEY}"` | -| `tavily` | `apiKey: "{env:TAVILY_API_KEY}"` | -| `perplexity` | `apiKey: "{env:PERPLEXITY_API_KEY}"` | -| `brave` | `apiKey: "{env:BRAVE_SEARCH_API_KEY}"` | -| `ollama-cloud` | `apiKey: "{env:OLLAMA_API_KEY}"` | -| `serpapi` | `apiKey: "{env:SERPAPI_API_KEY}"` | -| `keiro` | `apiKey: "{env:KEIRO_API_KEY}"` | +| Concern | Config | Who runs it | +|---|---|---| +| General web search backend | `provider` / `apiKey` / `endpoint` | Local `websearch` tool (crabcode) | +| Provider web search | `native.web` | LLM provider (`web_search`, Anthropic hosted web, OpenRouter web plugin) | +| X/Twitter search | `native.x` | xAI only (`x_search`) | -Only the provider names above are accepted. +`native.web` and the local `websearch` tool are **substitutes**: when `native.web` is on and the active provider supports hosted web search, local websearch is skipped. -## Keyed provider example +`native.x` is a **complement**: just to make crabcode as capable as grok-build cli. -```jsonc title="crabcode.jsonc" +```jsonc +// Local Ollama Cloud web search + xAI x_search { "websearch": { - "provider": "keiro", - "apiKey": "{env:KEIRO_API_KEY}" + "provider": "ollama-cloud", + "apiKey": "{env:OLLAMA_API_KEY}", + "native": { + "web": false, + "x": true + } } } ``` -`endpoint` can override the default provider URL for testing or proxying. +## Defaults + +| Field | Default | +|---|---| +| `enabled` | `true` when unset | +| `provider` | `exa-hosted-mcp` (keyless, rate-limited) | +| `native.web` | `false` | +| `native.x` | `true` | + +Unsupported native flags are ignored (e.g. `native.x` on OpenAI). If `native.web` is true but the active provider has no hosted web tool (Ollama, Groq, …), crabcode falls back to the local adapter. + +## Native tool costs + +| Provider | Tool | API tool fee | Source | +|---|---|---|---| +| OpenAI | `web_search` | **$10 / 1k calls** + search-content tokens | [OpenAI](https://developers.openai.com/api/docs/pricing) | +| Anthropic | hosted web search | **$10 / 1k searches** + tokens | [Anthropic](https://platform.claude.com/docs/en/about-claude/pricing#web-search-tool) | +| xAI | `web_search` | **$5 / 1k successful calls** + tokens | [xAI](https://docs.x.ai/developers/pricing#tools-pricing) | +| xAI | `x_search` | **$5 / 1k successful calls** + tokens | [xAI](https://docs.x.ai/developers/pricing#tools-pricing) | + +These fees apply on **API-key** auth. Models often invoke search multiple times per turn. + +**Subscription / OAuth** logins (ChatGPT, Claude, SuperGrok, …) use the provider’s subscription transport instead — native search is included in plan quota / rate limits, not billed as the API tool fees above. + +**Local adapters** bill (or rate-limit) through their own APIs — `exa-hosted-mcp` and `firecrawl-hosted-mcp` are keyless/rate-limited; Exa/Tavily/Brave/Ollama Cloud/etc. use your key. + +Default is local websearch + `native.x` on xAI. Set `native.web: true` to use provider-hosted web search instead (API tool fees may apply). + +## Providers (local adapters) + +| Provider | Auth | Notes | +|---|---|---| +| `exa-hosted-mcp` | None | Default. Hosted MCP bridge; rate-limited | +| `firecrawl-hosted-mcp` | None (optional key) | Firecrawl hosted MCP bridge; rate-limited without a key | +| `exa` | `EXA_API_KEY` | Direct Exa API | +| `tavily` | `TAVILY_API_KEY` | Tavily search API | +| `perplexity` | `PERPLEXITY_API_KEY` | Perplexity Search API | +| `brave` | `BRAVE_API_KEY` | Brave Search API | +| `ollama-cloud` | `OLLAMA_API_KEY` (required) | Ollama Cloud `POST /api/web_search` — separate HTTP API, **not** a chat-native tool | +| `serpapi` | `SERPAPI_API_KEY` | SerpAPI results from Google, DuckDuckGo, etc. | +| `keiro` | `KEIRO_API_KEY` | Keiro search API | +| `parallel` | `PARALLEL_API_KEY` | Parallel search API | +| `tako` | `TAKO_API_KEY` | Tako search API | diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index bad09985..df9c40a2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -484,3 +484,11 @@ I think this is how the TUI works already anyway right? - [x] I want to add 'g e' to scroll down. - [ ] I wanna be able to cancel queued messages if needed. + +- [x] Hosted search + +- [ ] Asking crabcode to run some tui like `lazygitrs` is causing it to crash the agent. + +- [ ] Extra padding in non compact mode. Or idk. controllable in tui? field? Right now it's close to the edge and it only looks good in some terminals. + +- [ ] /btw command diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 3b3f3ff8..3c0e75c1 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -68,7 +68,7 @@ pub async fn run_subagent( let scoped_registry = build_scoped_registry(full_registry, &agent).await; - let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( + let mut aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( &scoped_registry, sender.clone(), agent.name.clone(), @@ -79,6 +79,30 @@ pub async fn run_subagent( cancel_token.clone(), ) .await; + let hosted_selection = match crate::config::ConfigLoader::load() { + Ok(loaded) => { + let ws = &loaded.merged_config.websearch; + if ws.enabled.unwrap_or(true) { + Some( + crate::aisdk::providers::hosted_search::HostedSearchSelection { + web: ws.native.web_enabled(), + x: ws.native.x_enabled(), + }, + ) + } else { + None + } + } + Err(_) => Some(crate::aisdk::providers::hosted_search::HostedSearchSelection::DEFAULT), + }; + if let Some(selection) = hosted_selection { + if selection.web || selection.x { + aisdk_tools.extend(crate::aisdk::providers::hosted_search::tools_for( + &session.provider_name, + selection, + )); + } + } let system_prompt = agent .instructions @@ -154,6 +178,19 @@ pub async fn run_subagent( .unwrap_or(1); tool_call_count = tool_call_count.saturating_add(calls); } + ChunkType::ProviderToolCall(payload) => { + tool_call_count = tool_call_count.saturating_add(1); + if let Some(sender) = sender.as_ref() { + let (calls, result) = + crate::llm::client::provider_tool_call_ui_events(&payload); + if !calls.is_empty() { + let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(calls)); + } + if let Some(result) = result { + let _ = sender.send(crate::llm::ChunkMessage::ToolResult(result)); + } + } + } ChunkType::Failed(err) => { crate::emit_log!( "[SUBAGENT] stream_failed session_id={} subagent_type={} duration_ms={} error={}", diff --git a/src/aisdk/chunk.rs b/src/aisdk/chunk.rs index 70690ba6..31a547b9 100644 --- a/src/aisdk/chunk.rs +++ b/src/aisdk/chunk.rs @@ -4,13 +4,28 @@ pub enum ChunkType { Text(String), Reasoning(String), ToolCall(String), - AssistantMessagePhase { phase: Option }, - ResponseCompleted { end_turn: Option }, + /// Provider-executed tool lifecycle (hosted search, etc.). + /// + /// Display / observability only — must never enter the client tool-execute + /// loop. Payload JSON: + /// `{ "id", "name", "status": "running"|"completed"|"failed", "arguments"?, "output"? }`. + ProviderToolCall(String), + AssistantMessagePhase { + phase: Option, + }, + ResponseCompleted { + end_turn: Option, + }, Retry(crate::retry::RetryStatus), - StreamRollback { text: String, reasoning: String }, + StreamRollback { + text: String, + reasoning: String, + }, Warning(String), Metadata(String), - End { reason: Option }, + End { + reason: Option, + }, RetryableFailure(crate::retry::RetryError), Failed(String), Incomplete(String), diff --git a/src/aisdk/providers/anthropic.rs b/src/aisdk/providers/anthropic.rs index c4f3f6fd..e92a33f1 100644 --- a/src/aisdk/providers/anthropic.rs +++ b/src/aisdk/providers/anthropic.rs @@ -111,17 +111,25 @@ impl Provider for Anthropic { // immediately followed by tool_result blocks in one user message. let user_messages = anthropic_messages(messages); - let tool_params: Vec = tools - .iter() - .map(|t| { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); - serde_json::json!({ - "name": t.name, - "description": t.description, - "input_schema": schema, - }) - }) - .collect(); + let mut tool_params: Vec = Vec::new(); + let mut has_hosted_search = false; + for t in tools { + match &t.transport { + crate::aisdk::tool::ToolTransport::ProviderNative(value) => { + has_hosted_search = true; + tool_params.push(value.clone()); + } + crate::aisdk::tool::ToolTransport::OpenRouterPlugin(_) => {} + crate::aisdk::tool::ToolTransport::ClientFunction => { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + tool_params.push(serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": schema, + })); + } + } + } let mut body = serde_json::json!({ "model": self.model_name, @@ -156,6 +164,10 @@ impl Provider for Anthropic { request_headers.insert("x-api-key", self.api_key.parse().unwrap()); } request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + if has_hosted_search { + // Hosted web_search tool requires the anthropic-beta header. + request_headers.insert("anthropic-beta", "web-search-2025-03-05".parse().unwrap()); + } let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs( @@ -228,9 +240,17 @@ fn anthropic_stream_chunk( } None } - "content_block_start" => anthropic_tool_call_start(value) - .map(ChunkType::ToolCall) - .map(Ok), + "content_block_start" => { + if let Some(payload) = anthropic_hosted_search_start(value) { + Some(Ok(ChunkType::ProviderToolCall(payload))) + } else if let Some(payload) = anthropic_hosted_search_result(value) { + Some(Ok(ChunkType::ProviderToolCall(payload))) + } else { + anthropic_tool_call_start(value) + .map(ChunkType::ToolCall) + .map(Ok) + } + } "content_block_delta" => anthropic_content_block_delta(value).map(Ok), "message_delta" => { // Final usage wins for cache_read / cache_creation. @@ -326,13 +346,79 @@ fn anthropic_message_delta(value: &serde_json::Value) -> Option { } } +fn anthropic_hosted_search_start(value: &serde_json::Value) -> Option { + let content_block = value.get("content_block")?; + if content_block.get("type").and_then(|v| v.as_str()) != Some("server_tool_use") { + return None; + } + let id = content_block + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("hosted_search"); + let name = content_block + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("web_search"); + let args = content_block + .get("input") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + Some( + serde_json::json!({ + "id": id, + "name": name, + "status": "running", + "provider_executed": true, + "arguments": args, + }) + .to_string(), + ) +} + +fn anthropic_hosted_search_result(value: &serde_json::Value) -> Option { + let content_block = value.get("content_block")?; + let block_type = content_block.get("type").and_then(|v| v.as_str())?; + let failed = block_type == "web_search_tool_result_error"; + if block_type != "web_search_tool_result" && !failed { + return None; + } + let id = content_block + .get("tool_use_id") + .or_else(|| content_block.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("hosted_search"); + let output = content_block + .get("content") + .cloned() + .unwrap_or_else(|| content_block.clone()); + Some( + serde_json::json!({ + "id": id, + "name": "web_search", + "status": if failed { "failed" } else { "completed" }, + "provider_executed": true, + "output": output, + }) + .to_string(), + ) +} + fn anthropic_tool_call_start(value: &serde_json::Value) -> Option { let content_block = value.get("content_block")?; - if content_block + let block_type = content_block .get("type") - .and_then(|block_type| block_type.as_str()) - != Some("tool_use") - { + .and_then(|block_type| block_type.as_str())?; + + // Hosted web_search runs server-side; ignore those content blocks for the + // client tool loop (results come back as text / citations). + if matches!( + block_type, + "server_tool_use" | "web_search_tool_result" | "web_search_tool_result_error" + ) { + return None; + } + + if block_type != "tool_use" { return None; } diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index 3cbe73e3..c389b850 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -19,6 +19,7 @@ pub struct OpenAICompatible { provider_name: String, reasoning_effort: Option, prompt_cache_key: Option, + /// Vercel AI Gateway: set `providerOptions.gateway.caching = "auto"` so /// Anthropic (and MiniMax) models get explicit cache breakpoints. gateway_caching_auto: bool, @@ -38,6 +39,7 @@ pub struct OpenAICompatibleBuilder { provider_name: Option, reasoning_effort: Option, prompt_cache_key: Option, + gateway_caching_auto: bool, } @@ -91,6 +93,7 @@ impl OpenAICompatibleBuilder { .unwrap_or_else(|| "openai-compatible".to_string()), reasoning_effort: self.reasoning_effort, prompt_cache_key: self.prompt_cache_key, + gateway_caching_auto: self.gateway_caching_auto, }) } @@ -123,20 +126,30 @@ impl Provider for OpenAICompatible { openai_compatible_requires_tool_call_reasoning_content(self); let chat_messages = openai_compatible_messages(messages, include_empty_tool_call_reasoning); - let tool_params: Vec = tools - .iter() - .map(|t| { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); - serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": schema, - } - }) - }) - .collect(); + let mut tool_params: Vec = Vec::new(); + let mut plugins: Vec = Vec::new(); + for t in tools { + match &t.transport { + crate::aisdk::tool::ToolTransport::ClientFunction => { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + tool_params.push(serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": schema, + } + })); + } + crate::aisdk::tool::ToolTransport::ProviderNative(value) => { + // Some OpenAI-compatible gateways accept Responses-style tools. + tool_params.push(value.clone()); + } + crate::aisdk::tool::ToolTransport::OpenRouterPlugin(value) => { + plugins.push(value.clone()); + } + } + } let mut body = serde_json::json!({ "model": self.model_name, @@ -158,6 +171,10 @@ impl Provider for OpenAICompatible { } } + if !plugins.is_empty() { + body["plugins"] = serde_json::Value::Array(plugins); + } + // AI Gateway Chat Completions: enable automatic prompt caching for // providers that need explicit markers (Anthropic / MiniMax). // https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching diff --git a/src/aisdk/providers/hosted_search.rs b/src/aisdk/providers/hosted_search.rs new file mode 100644 index 00000000..f8159ce5 --- /dev/null +++ b/src/aisdk/providers/hosted_search.rs @@ -0,0 +1,282 @@ +//! Provider-executed (server-side) search tools. +//! +//! These are normal aisdk [`Tool`] values with a provider-native transport. +//! Pass them in the same `tools` list as local tools: +//! +//! ```ignore +//! tools.push(openai::tools::web_search()); +//! tools.push(xai::tools::x_search()); +//! stream_with_tools(provider, messages, tools, ...) +//! ``` +//! +//! Host policy chooses *which* tools to include. This module only defines them. + +use schemars::Schema; +use serde_json::{json, Value}; + +use crate::aisdk::tool::{Tool, ToolExecute, ToolTransport}; + +/// Providers that currently expose hosted web search via their native APIs. +pub fn supports_hosted_web_search(provider_name: &str) -> bool { + matches!( + provider_name.to_ascii_lowercase().as_str(), + "xai" | "openai" | "anthropic" | "openrouter" + ) +} + +/// Which provider-executed search tools a host wants to attach. +/// +/// Host config (e.g. `websearch.native`) maps into this. aisdk does not read +/// product config — callers pass an explicit selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostedSearchSelection { + /// Attach provider web search when supported (`web_search` / Anthropic hosted web / OpenRouter web). + pub web: bool, + /// Attach provider X/Twitter search when supported (`x_search`). Currently xAI-only. + pub x: bool, +} + +impl HostedSearchSelection { + pub const ALL: Self = Self { web: true, x: true }; + pub const NONE: Self = Self { + web: false, + x: false, + }; + /// Product default: local websearch + complementary provider X search when available. + pub const DEFAULT: Self = Self { + web: false, + x: true, + }; +} + +/// Whether the host should also register its local `websearch` tool. +/// +/// Local websearch is skipped only when native **web** is requested and the +/// provider can supply it. Native `x` is a complement and does not displace local web. +pub fn should_register_local_websearch(provider_name: &str, native_web: bool) -> bool { + !(native_web && supports_hosted_web_search(provider_name)) +} + +fn provider_tool(name: &str, description: &str, transport: ToolTransport) -> Tool { + Tool::builder() + .name(name) + .description(description) + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(|_input| async move { + Err::( + "provider-executed tool; the model provider runs this server-side".to_string(), + ) + })) + .transport(transport) + .build() + .expect("provider tool builder inputs are complete") +} + +pub mod openai { + use super::*; + + pub mod tools { + use super::*; + + /// OpenAI Responses hosted web search: `{ "type": "web_search" }`. + pub fn web_search() -> Tool { + provider_tool( + "web_search", + "OpenAI provider-executed web search.", + ToolTransport::ProviderNative(json!({ "type": "web_search" })), + ) + } + } +} + +pub mod xai { + use super::*; + + pub mod tools { + use super::*; + + /// xAI Responses hosted web search: `{ "type": "web_search" }`. + pub fn web_search() -> Tool { + provider_tool( + "web_search", + "xAI provider-executed web search.", + ToolTransport::ProviderNative(json!({ "type": "web_search" })), + ) + } + + /// xAI Responses hosted X/Twitter search: `{ "type": "x_search" }`. + pub fn x_search() -> Tool { + provider_tool( + "x_search", + "xAI provider-executed X/Twitter search.", + ToolTransport::ProviderNative(json!({ "type": "x_search" })), + ) + } + } +} + +pub mod anthropic { + use super::*; + + pub mod tools { + use super::*; + + /// Anthropic hosted web search tool (`web_search_20250305`). + pub fn web_search() -> Tool { + provider_tool( + "web_search", + "Anthropic provider-executed web search.", + ToolTransport::ProviderNative(json!({ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5 + })), + ) + } + } +} + +pub mod openrouter { + use super::*; + + pub mod tools { + use super::*; + + /// OpenRouter chat-completions web plugin (`plugins: [{ "id": "web" }]`). + pub fn web() -> Tool { + provider_tool( + "web", + "OpenRouter provider-executed web search plugin.", + ToolTransport::OpenRouterPlugin(json!({ "id": "web" })), + ) + } + } +} + +/// Hosted search tools for a provider filtered by [`HostedSearchSelection`]. +/// +/// Unknown / unsupported providers return an empty list. Unsupported selection +/// flags are ignored (e.g. `x` on OpenAI). +pub fn tools_for(provider_name: &str, selection: HostedSearchSelection) -> Vec { + match provider_name.to_ascii_lowercase().as_str() { + "xai" => { + let mut tools = Vec::new(); + if selection.web { + tools.push(xai::tools::web_search()); + } + if selection.x { + tools.push(xai::tools::x_search()); + } + tools + } + "openai" if selection.web => vec![openai::tools::web_search()], + "anthropic" if selection.web => vec![anthropic::tools::web_search()], + "openrouter" if selection.web => vec![openrouter::tools::web()], + _ => Vec::new(), + } +} + +/// All hosted search tools for a provider (`web` + `x` when available). +pub fn default_tools_for(provider_name: &str) -> Vec { + tools_for(provider_name, HostedSearchSelection::ALL) +} + +/// Native `tools` array fragments for provider-native transports. +pub fn provider_native_tool_values(tools: &[Tool]) -> Vec { + tools + .iter() + .filter_map(|tool| match &tool.transport { + ToolTransport::ProviderNative(value) => Some(value.clone()), + _ => None, + }) + .collect() +} + +/// OpenRouter `plugins` fragments from provider-executed tools. +pub fn openrouter_plugin_values(tools: &[Tool]) -> Vec { + tools + .iter() + .filter_map(|tool| match &tool.transport { + ToolTransport::OpenRouterPlugin(value) => Some(value.clone()), + _ => None, + }) + .collect() +} + +/// True when any tool in the list is provider-executed hosted search. +pub fn has_provider_executed_tools(tools: &[Tool]) -> bool { + tools.iter().any(|tool| tool.is_provider_executed()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlist_matches_supported_providers() { + assert!(supports_hosted_web_search("xai")); + assert!(supports_hosted_web_search("OpenAI")); + assert!(supports_hosted_web_search("anthropic")); + assert!(supports_hosted_web_search("openrouter")); + assert!(!supports_hosted_web_search("google")); + assert!(!supports_hosted_web_search("groq")); + } + + #[test] + fn xai_defaults_include_web_and_x_search() { + let tools = default_tools_for("xai"); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name, "web_search"); + assert_eq!(tools[1].name, "x_search"); + let native = provider_native_tool_values(&tools); + assert_eq!(native[0]["type"], "web_search"); + assert_eq!(native[1]["type"], "x_search"); + } + + #[test] + fn xai_selection_can_keep_x_without_web() { + let tools = tools_for( + "xai", + HostedSearchSelection { + web: false, + x: true, + }, + ); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "x_search"); + } + + #[test] + fn openai_default_is_web_only() { + let tools = default_tools_for("openai"); + assert_eq!( + provider_native_tool_values(&tools), + vec![json!({ "type": "web_search" })] + ); + assert!(tools_for( + "openai", + HostedSearchSelection { + web: false, + x: true, + }, + ) + .is_empty()); + } + + #[test] + fn openrouter_uses_plugin_transport() { + let tools = default_tools_for("openrouter"); + assert!(provider_native_tool_values(&tools).is_empty()); + assert_eq!( + openrouter_plugin_values(&tools), + vec![json!({ "id": "web" })] + ); + } + + #[test] + fn native_web_skips_local_for_supported() { + assert!(!should_register_local_websearch("xai", true)); + assert!(should_register_local_websearch("xai", false)); + assert!(should_register_local_websearch("groq", true)); + } +} diff --git a/src/aisdk/providers/mod.rs b/src/aisdk/providers/mod.rs index 92e76ff3..5cf2e115 100644 --- a/src/aisdk/providers/mod.rs +++ b/src/aisdk/providers/mod.rs @@ -1,7 +1,13 @@ pub mod anthropic; pub mod compatible; +pub mod hosted_search; pub mod openai; +#[allow(unused_imports)] +pub use hosted_search::{ + default_tools_for, should_register_local_websearch, tools_for, HostedSearchSelection, +}; + pub use anthropic::Anthropic; pub use compatible::OpenAICompatible; pub use openai::OpenAI; diff --git a/src/aisdk/providers/openai.rs b/src/aisdk/providers/openai.rs index 0552df4e..ebbd68bc 100644 --- a/src/aisdk/providers/openai.rs +++ b/src/aisdk/providers/openai.rs @@ -59,6 +59,7 @@ pub struct OpenAI { responses_websocket: bool, responses_lite: bool, prompt_cache_key: Option, + response_retry_policy: Option>, websocket_state: Arc>, } @@ -97,6 +98,7 @@ pub struct OpenAIBuilder { responses_websocket: bool, responses_lite: bool, prompt_cache_key: Option, + response_retry_policy: Option>, } @@ -212,6 +214,7 @@ impl OpenAIBuilder { responses_websocket: self.responses_websocket, responses_lite: self.responses_lite, prompt_cache_key: self.prompt_cache_key, + response_retry_policy: self.response_retry_policy, websocket_state: Arc::new(Mutex::new(OpenAIWebsocketState::default())), }) @@ -575,30 +578,37 @@ impl OpenAI { mut input: Vec, tools: &[Tool], ) -> serde_json::Value { - let tool_params: Vec = tools - .iter() - .map(|t| { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); - let mut tool = serde_json::json!({ - "type": "function", - "name": t.name, - "description": t.description, - "parameters": schema, - }); - - if let Some(strict) = self.tool_strict_override { - tool = serde_json::json!({ + let mut tool_params: Vec = Vec::new(); + for t in tools { + match &t.transport { + crate::aisdk::tool::ToolTransport::ProviderNative(value) => { + tool_params.push(value.clone()); + } + crate::aisdk::tool::ToolTransport::OpenRouterPlugin(_) => { + // Plugins are not Responses `tools` entries. + } + crate::aisdk::tool::ToolTransport::ClientFunction => { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let mut tool = serde_json::json!({ "type": "function", "name": t.name, - "strict": strict, - "parameters": schema, "description": t.description, + "parameters": schema, }); - } - tool - }) - .collect(); + if let Some(strict) = self.tool_strict_override { + tool = serde_json::json!({ + "type": "function", + "name": t.name, + "strict": strict, + "parameters": schema, + "description": t.description, + }); + } + tool_params.push(tool); + } + } + } if self.responses_lite { let mut prefix = vec![serde_json::json!({ @@ -1536,10 +1546,12 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { _ => { if let Some(message_phase) = responses_assistant_message_phase_chunk(&value) { Some(Ok(message_phase)) + } else if let Some(payload) = responses_hosted_search_chunk(&value) { + // Provider-executed hosted search — UI only, never client execute. + Some(Ok(ChunkType::ProviderToolCall(payload))) } else if let Some(tool_call) = responses_function_call_chunk(&value) { + // Only known client function_call shapes. Some(Ok(ChunkType::ToolCall(tool_call))) - } else if event_type.contains("tool_call") { - Some(Ok(ChunkType::ToolCall(data.to_string()))) } else { None } @@ -1756,6 +1768,158 @@ fn parse_message_phase(phase: &str) -> Option { } } +/// True when an SSE event type is a provider-hosted search lifecycle event. +fn is_hosted_search_event(event_type: &str) -> bool { + let lower = event_type.to_ascii_lowercase(); + lower.contains("web_search") + || lower.contains("x_search") + || lower.contains("custom_tool") + || lower.contains("file_search") +} + +/// Client-executed function tool events (not provider-hosted search). +/// +/// Hosted search SSE names also contain `"tool_call"` and must not enter the +/// client tool accumulator: +/// - `response.web_search_call.*` +/// - `response.custom_tool_call_*` (xAI `x_search` streams as custom_tool_call) +/// - `response.file_search_call.*` +fn is_client_tool_call_event(event_type: &str) -> bool { + if !event_type.contains("tool_call") { + return false; + } + let lower = event_type.to_ascii_lowercase(); + // Hosted search + other provider-executed tools stay out of the client loop. + !(is_hosted_search_event(event_type) || lower.contains("code_interpreter")) +} + +fn hosted_search_item_name(item_type: &str) -> Option<&'static str> { + let lower = item_type.to_ascii_lowercase(); + // xAI streams x_search as `custom_tool_call`. + if lower.contains("x_search") || lower.contains("custom_tool") { + Some("x_search") + } else if lower.contains("web_search") { + Some("web_search") + } else if lower.contains("file_search") { + Some("file_search") + } else { + None + } +} + +fn hosted_search_status_from_event(event_type: &str) -> &'static str { + let lower = event_type.to_ascii_lowercase(); + if lower.contains("failed") || lower.contains("error") { + "failed" + } else if lower.contains("completed") || lower.ends_with(".done") { + "completed" + } else { + "running" + } +} + +/// Build a display-only ProviderToolCall payload from hosted-search SSE. +fn responses_hosted_search_chunk(value: &serde_json::Value) -> Option { + let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if !is_hosted_search_event(event_type) + && !matches!( + event_type, + "response.output_item.added" | "response.output_item.done" + ) + { + return None; + } + + // Prefer nested item (output_item.added/done); else top-level fields. + let item = value.get("item").unwrap_or(value); + let item_type = item.get("type").and_then(|v| v.as_str()).or_else(|| { + // custom_tool_call_input.* may not nest under item + if is_hosted_search_event(event_type) { + Some(event_type) + } else { + None + } + })?; + + let name = if let Some(n) = item.get("name").and_then(|v| v.as_str()) { + if n.eq_ignore_ascii_case("x_search") || n.eq_ignore_ascii_case("web_search") { + n.to_string() + } else if is_hosted_search_event(item_type) || is_hosted_search_event(event_type) { + hosted_search_item_name(item_type) + .or_else(|| hosted_search_item_name(event_type)) + .unwrap_or("web_search") + .to_string() + } else { + return None; + } + } else { + hosted_search_item_name(item_type) + .or_else(|| hosted_search_item_name(event_type))? + .to_string() + }; + + // Must look like hosted search — don't mis-classify client function_call items. + if !is_hosted_search_event(item_type) + && !is_hosted_search_event(event_type) + && !matches!(name.as_str(), "x_search" | "web_search" | "file_search") + { + return None; + } + if item_type == "function_call" { + return None; + } + + let id = item + .get("call_id") + .or_else(|| item.get("id")) + .or_else(|| value.get("item_id")) + .or_else(|| value.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("hosted_search") + .to_string(); + + let status = if event_type == "response.output_item.done" { + "completed" + } else if event_type == "response.output_item.added" { + "running" + } else { + hosted_search_status_from_event(event_type) + }; + + let mut payload = serde_json::Map::new(); + payload.insert("id".into(), serde_json::Value::String(id)); + payload.insert("name".into(), serde_json::Value::String(name)); + payload.insert("status".into(), serde_json::Value::String(status.into())); + payload.insert("provider_executed".into(), serde_json::Value::Bool(true)); + + // Arguments / query from various shapes + let args = item + .get("arguments") + .cloned() + .or_else(|| item.get("input").cloned()) + .or_else(|| item.get("action").cloned()) + .or_else(|| value.get("input").cloned()) + .or_else(|| value.get("delta").cloned()); + if let Some(args) = args { + let args_val = match args { + serde_json::Value::String(s) => serde_json::from_str::(&s) + .unwrap_or(serde_json::Value::String(s)), + other => other, + }; + payload.insert("arguments".into(), args_val); + } + + if let Some(output) = item + .get("output") + .cloned() + .or_else(|| item.get("result").cloned()) + { + payload.insert("output".into(), output); + } + + serde_json::to_string(&serde_json::Value::Object(payload)).ok() +} + fn responses_function_call_chunk(value: &serde_json::Value) -> Option { let event_type = value.get("type").and_then(|v| v.as_str())?; @@ -2019,11 +2183,12 @@ fn openai_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde mod tests { use super::{ add_responses_lite_header, build_openai_messages, build_websocket_request_body, - fresh_websocket_request_body, openai_chunk_is_terminal, request_snapshot_from_body, - response_sse_data_to_chunk, responses_function_call_chunk, websocket_connection_is_idle, - websocket_continuation_mode_after_idle_policy, websocket_continuation_mode_from_state, - OpenAI, OpenAIResponseSnapshot, OpenAIWebsocketState, WebsocketContinuationMode, - WebsocketStreamProgress, OPENAI_CODEX_WINDOW_ID_HEADER, OPENAI_RESPONSES_LITE_HEADER, + fresh_websocket_request_body, is_client_tool_call_event, openai_chunk_is_terminal, + request_snapshot_from_body, response_sse_data_to_chunk, responses_function_call_chunk, + websocket_connection_is_idle, websocket_continuation_mode_after_idle_policy, + websocket_continuation_mode_from_state, OpenAI, OpenAIResponseSnapshot, + OpenAIWebsocketState, WebsocketContinuationMode, WebsocketStreamProgress, + OPENAI_CODEX_WINDOW_ID_HEADER, OPENAI_RESPONSES_LITE_HEADER, OPENAI_RESPONSES_LITE_WS_METADATA_KEY, OPENAI_WEBSOCKET_FAILURES_BEFORE_FALLBACK, OPENAI_WEBSOCKET_IDLE_MAX, }; @@ -2240,6 +2405,80 @@ mod tests { )); } + #[test] + fn surfaces_hosted_search_sse_as_provider_tool_call() { + // xAI x_search streams as custom_tool_call_*; must not enter client tool loop, + // but should surface as ProviderToolCall for host observability. + for event_type in [ + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.done", + "response.web_search_call.in_progress", + "response.web_search_call.completed", + "response.web_search_call.searching", + ] { + let chunk = response_sse_data_to_chunk( + &serde_json::json!({ + "type": event_type, + "item_id": "ws_1", + "delta": "{\"query\":\"carlo_taleon\"}" + }) + .to_string(), + ); + match chunk { + Some(Ok(ChunkType::ProviderToolCall(payload))) => { + let parsed: serde_json::Value = + serde_json::from_str(&payload).expect("valid provider tool payload"); + assert_eq!(parsed["id"], "ws_1"); + assert!(parsed["provider_executed"].as_bool().unwrap_or(false)); + assert!( + matches!( + parsed["name"].as_str(), + Some("x_search") | Some("web_search") + ), + "unexpected name for {event_type}: {}", + parsed["name"] + ); + } + other => panic!("expected ProviderToolCall for {event_type}, got {other:?}"), + } + } + + let chunk = response_sse_data_to_chunk( + &serde_json::json!({ + "type": "response.output_item.done", + "item": { + "id": "ws_done", + "type": "web_search_call", + "status": "completed", + "action": {"query": "rust async"} + } + }) + .to_string(), + ); + match chunk { + Some(Ok(ChunkType::ProviderToolCall(payload))) => { + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(parsed["name"], "web_search"); + assert_eq!(parsed["status"], "completed"); + assert_eq!(parsed["id"], "ws_done"); + } + other => panic!("expected completed ProviderToolCall, got {other:?}"), + } + + assert!(!is_client_tool_call_event( + "response.custom_tool_call_input.delta" + )); + assert!(!is_client_tool_call_event( + "response.web_search_call.in_progress" + )); + // function_call_arguments.delta is handled by responses_function_call_chunk, + // not the tool_call catch-all (it doesn't contain "tool_call"). + assert!(!is_client_tool_call_event( + "response.function_call_arguments.delta" + )); + assert!(is_client_tool_call_event("chat.completion.tool_call.delta")); + } + #[test] fn maps_responses_function_call_item_to_tool_call_shape() { let event = serde_json::json!({ diff --git a/src/aisdk/response.rs b/src/aisdk/response.rs index ff4dd3fd..355499ee 100644 --- a/src/aisdk/response.rs +++ b/src/aisdk/response.rs @@ -264,6 +264,11 @@ pub async fn stream_with_tools( return; } } + Ok(ChunkType::ProviderToolCall(payload)) => { + // Hosted / server-side tools: forward for UI only. + emitted_non_replayable_output = true; + let _ = tx_loop.send(ChunkType::ProviderToolCall(payload)); + } Ok(ChunkType::End { reason }) => { // Processed internally — NOT forwarded to tx_loop. // Forwarding End would cause relay_stream_to_sender diff --git a/src/aisdk/tool.rs b/src/aisdk/tool.rs index ca89d79f..ed60b839 100644 --- a/src/aisdk/tool.rs +++ b/src/aisdk/tool.rs @@ -81,12 +81,28 @@ impl std::fmt::Debug for ToolExecute { } } +/// How a tool is exposed on the wire. +/// +/// Client tools become provider `function` tools and run locally via [`Tool::execute`]. +/// Provider-executed tools carry a native request fragment (or OpenRouter plugin) +/// and are run by the model provider — same call-site shape as Rig / Vercel AI SDK. +#[derive(Debug, Clone, Default)] +pub enum ToolTransport { + #[default] + ClientFunction, + /// Native tool object, e.g. `{ "type": "web_search" }` or Anthropic hosted tools. + ProviderNative(serde_json::Value), + /// OpenRouter `plugins` entry, e.g. `{ "id": "web" }`. + OpenRouterPlugin(serde_json::Value), +} + #[derive(Clone)] pub struct Tool { pub name: String, pub description: String, pub input_schema: Schema, pub execute: ToolExecute, + pub transport: ToolTransport, } impl std::fmt::Debug for Tool { @@ -94,6 +110,7 @@ impl std::fmt::Debug for Tool { f.debug_struct("Tool") .field("name", &self.name) .field("description", &self.description) + .field("transport", &self.transport) .finish() } } @@ -102,6 +119,10 @@ impl Tool { pub fn builder() -> ToolBuilder { ToolBuilder::default() } + + pub fn is_provider_executed(&self) -> bool { + !matches!(self.transport, ToolTransport::ClientFunction) + } } #[derive(Default)] @@ -110,6 +131,7 @@ pub struct ToolBuilder { description: Option, input_schema: Option, execute: Option, + transport: ToolTransport, } impl ToolBuilder { @@ -133,12 +155,18 @@ impl ToolBuilder { self } + pub fn transport(mut self, transport: ToolTransport) -> Self { + self.transport = transport; + self + } + pub fn build(self) -> Result { Ok(Tool { name: self.name.ok_or("name is required")?, description: self.description.ok_or("description is required")?, input_schema: self.input_schema.ok_or("input_schema is required")?, execute: self.execute.ok_or("execute is required")?, + transport: self.transport, }) } } diff --git a/src/app.rs b/src/app.rs index 14c42894..0030f2a2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9671,8 +9671,58 @@ impl App { }); let call_id = call.id.clone(); - msg.add_tool_call_part(call.id, call.function.name, args_value); - inserted.push((call_id, idx)); + // Upsert: hosted search may emit running then completed with same id. + if let Some(existing) = msg.parts.iter_mut().find(|part| { + part.part_type == "tool_call" + && part.tool_id() == Some(call_id.as_str()) + }) { + if let Some(obj) = existing.data.as_object_mut() { + obj.insert( + "name".into(), + serde_json::Value::String(call.function.name.clone()), + ); + // Hosted search completed events sometimes omit sources / + // wipe query to ""; don't replace richer running args. + let incoming_hollow = + crate::llm::client::hosted_search_args_are_hollow(&args_value); + let existing_hollow = obj + .get("args") + .map(crate::llm::client::hosted_search_args_are_hollow) + .unwrap_or(true); + if !incoming_hollow || existing_hollow { + obj.insert("args".into(), args_value); + } + if matches!( + call.function.name.as_str(), + "x_search" | "web_search" | "file_search" + ) { + obj.insert( + "provider_executed".into(), + serde_json::Value::Bool(true), + ); + } + } + } else { + msg.add_tool_call_part( + call.id.clone(), + call.function.name.clone(), + args_value, + ); + if matches!( + call.function.name.as_str(), + "x_search" | "web_search" | "file_search" + ) { + if let Some(part) = msg.parts.last_mut() { + if let Some(obj) = part.data.as_object_mut() { + obj.insert( + "provider_executed".into(), + serde_json::Value::Bool(true), + ); + } + } + } + inserted.push((call_id, idx)); + } } chat.mark_streaming_tool_render_pending(idx); } @@ -9720,6 +9770,12 @@ impl App { }; v["id"] = serde_json::Value::String(result.tool_call_id.clone()); v["name"] = serde_json::Value::String(result.name.clone()); + if matches!( + result.name.as_str(), + "x_search" | "web_search" | "file_search" + ) { + v["provider_executed"] = serde_json::Value::Bool(true); + } if let Ok(payload) = serde_json::from_str::(&result.content) { @@ -9761,6 +9817,44 @@ impl App { v["output_preview"] = serde_json::Value::String(result.content.clone()); } + // Hosted search completed SSE often omits sources / clears query; + // reuse running tool_call args for preview + result card header. + if matches!( + result.name.as_str(), + "web_search" | "x_search" | "file_search" + ) { + if let Some(args) = msg + .tool_call_part_data(&result.tool_call_id) + .and_then(|part| part.get("args")) + .filter(|a| !crate::llm::client::hosted_search_args_are_hollow(a)) + .cloned() + { + let result_args_hollow = v + .get("args") + .map(crate::llm::client::hosted_search_args_are_hollow) + .unwrap_or(true); + if result_args_hollow { + v["args"] = args.clone(); + } + + let preview_is_stub = v + .get("output_preview") + .and_then(|p| p.as_str()) + .map(|s| s.trim().is_empty() || s.starts_with("Provider-executed ")) + .unwrap_or(true); + if preview_is_stub { + let enriched = crate::llm::client::hosted_search_output_preview( + &result.name, + v.get("status").and_then(|s| s.as_str()).unwrap_or("ok"), + &serde_json::json!({ "arguments": args }), + ); + if !enriched.starts_with("Provider-executed ") { + v["output_preview"] = serde_json::Value::String(enriched); + } + } + } + } + if msg.role == crate::session::types::MessageRole::Assistant { msg.add_or_update_tool_result_part(v); } else { diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 9be27b4e..74824bf0 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -357,6 +357,8 @@ pub enum WebsearchProvider { OllamaCloud, SerpApi, Keiro, + Parallel, + Tako, } impl WebsearchProvider { @@ -371,13 +373,43 @@ impl WebsearchProvider { Self::OllamaCloud => "ollama-cloud", Self::SerpApi => "serpapi", Self::Keiro => "keiro", + Self::Parallel => "parallel", + Self::Tako => "tako", } } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebsearchNativeConfig { + /// Provider-executed web search (`web_search` / Anthropic hosted web / OpenRouter web plugin). + /// Default false. When true, substitutes for the local `websearch` tool if the active provider supports it. + pub web: Option, + /// Provider-executed X/Twitter search (`x_search`). Default true. xAI-only; ignored elsewhere. + /// Independent of `web` — can stay on while a local backend handles web search. + pub x: Option, +} + +impl WebsearchNativeConfig { + pub fn web_enabled(&self) -> bool { + self.web.unwrap_or(false) + } + + pub fn x_enabled(&self) -> bool { + self.x.unwrap_or(true) + } +} + +impl Default for WebsearchNativeConfig { + fn default() -> Self { + Self { web: None, x: None } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebsearchConfig { pub enabled: Option, + /// Provider-executed search tools. Host policy only — aisdk receives the resulting tools list. + pub native: WebsearchNativeConfig, pub provider: WebsearchProvider, pub endpoint: Option, pub api_key: Option, @@ -387,6 +419,7 @@ impl Default for WebsearchConfig { fn default() -> Self { Self { enabled: None, + native: WebsearchNativeConfig::default(), provider: WebsearchProvider::ExaHostedMcp, endpoint: None, api_key: None, @@ -1716,12 +1749,40 @@ fn parse_websearch(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) - } } + if let Some(native) = map.get("native") { + match native.as_object() { + Some(native_map) => { + if let Some(web) = native_map.get("web") { + if let Some(v) = web.as_bool() { + websearch.native.web = Some(v); + } else { + diagnostics + .warnings + .push("websearch.native.web must be a boolean".to_string()); + } + } + if let Some(x) = native_map.get("x") { + if let Some(v) = x.as_bool() { + websearch.native.x = Some(v); + } else { + diagnostics + .warnings + .push("websearch.native.x must be a boolean".to_string()); + } + } + } + None => diagnostics + .warnings + .push("websearch.native must be an object".to_string()), + } + } + if let Some(provider) = map.get("provider") { if let Some(raw) = provider.as_str() { match parse_websearch_provider(raw) { Some(provider) => websearch.provider = provider, _ => diagnostics.warnings.push(format!( - "websearch.provider must be one of: exa-hosted-mcp, firecrawl-hosted-mcp, exa, tavily, perplexity, brave, ollama-cloud, serpapi, keiro; got {}", + "websearch.provider must be one of: exa-hosted-mcp, firecrawl-hosted-mcp, exa, tavily, perplexity, brave, ollama-cloud, serpapi, keiro, parallel, tako; got {}", raw )), } @@ -1788,6 +1849,8 @@ fn parse_websearch_provider(raw: &str) -> Option { "ollama-cloud" => Some(WebsearchProvider::OllamaCloud), "serpapi" => Some(WebsearchProvider::SerpApi), "keiro" => Some(WebsearchProvider::Keiro), + "parallel" => Some(WebsearchProvider::Parallel), + "tako" => Some(WebsearchProvider::Tako), _ => None, } } @@ -2907,6 +2970,8 @@ mod tests { ); assert_eq!(config.websearch.enabled, Some(true)); + assert!(!config.websearch.native.web_enabled()); + assert!(config.websearch.native.x_enabled()); assert_eq!(config.websearch.provider, WebsearchProvider::Exa); assert_eq!(config.websearch.provider.as_str(), "exa"); assert_eq!( @@ -2917,6 +2982,28 @@ mod tests { assert!(diagnostics.warnings.is_empty()); } + #[test] + fn parses_websearch_native_flags() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "websearch": { + "native": { + "web": false, + "x": true + } + } + }), + &mut diagnostics, + ); + + assert_eq!(config.websearch.native.web, Some(false)); + assert_eq!(config.websearch.native.x, Some(true)); + assert!(!config.websearch.native.web_enabled()); + assert!(config.websearch.native.x_enabled()); + assert!(diagnostics.warnings.is_empty()); + } + #[test] fn parses_websearch_boolean_shorthand() { let mut diagnostics = ConfigDiagnostics::default(); @@ -2983,6 +3070,14 @@ mod tests { parse_websearch_provider("keiro"), Some(WebsearchProvider::Keiro) ); + assert_eq!( + parse_websearch_provider("parallel"), + Some(WebsearchProvider::Parallel) + ); + assert_eq!( + parse_websearch_provider("tako"), + Some(WebsearchProvider::Tako) + ); assert_eq!(parse_websearch_provider("ollama"), None); assert_eq!(parse_websearch_provider("keiro-labs"), None); assert_eq!(parse_websearch_provider("keirolabs"), None); diff --git a/src/llm/client.rs b/src/llm/client.rs index 60b3dd2c..d4ddd94d 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -283,6 +283,219 @@ impl ToolCallLogInfo { } } +/// Map a provider-executed tool payload into UI ToolCalls / ToolResult events. +/// +/// Hosted search never runs client-side; these events are display-only. + +fn is_provider_executed_tool_part(obj: &serde_json::Map) -> bool { + if obj + .get("provider_executed") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return true; + } + matches!( + obj.get("name").and_then(|v| v.as_str()), + Some("x_search") | Some("web_search") | Some("file_search") + ) +} + +/// Build a display preview for provider-executed hosted search. +/// Prefer explicit `output`; otherwise summarize `arguments.sources` / `action` +/// (xAI web_search often only returns sources on the call args). +pub(crate) fn hosted_search_output_preview( + name: &str, + status: &str, + value: &serde_json::Value, +) -> String { + if let Some(output) = value.get("output") { + let preview = match output { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + if !preview.trim().is_empty() { + return preview; + } + } + + // Prefer arguments, then action (OpenAI Responses nests query/sources there). + let args = value + .get("arguments") + .or_else(|| value.get("action")) + .unwrap_or(&serde_json::Value::Null); + + let query = args + .get("query") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(""); + + let provider_label = match name { + "x_search" => "native (x)", + _ => "native", + }; + + let sources = args + .get("sources") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if query.is_empty() && sources.is_empty() { + return format!("Provider-executed {name} {status}."); + } + + let results: Vec = sources + .iter() + .filter_map(|source| { + let url = source + .get("url") + .and_then(|u| u.as_str()) + .or_else(|| source.as_str()) + .map(str::trim) + .filter(|u| !u.is_empty())? + .to_string(); + let title = source + .get("title") + .and_then(|t| t.as_str()) + .map(str::trim) + .filter(|t| !t.is_empty()) + .unwrap_or(url.as_str()) + .to_string(); + let snippet = source + .get("snippet") + .or_else(|| source.get("description")) + .and_then(|s| s.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let date = source + .get("date") + .or_else(|| source.get("published_date")) + .and_then(|s| s.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + Some(crate::tools::websearch::SearchItem { + title, + url, + snippet, + date, + }) + }) + .take(8) + .collect(); + + // x_search usually has no URL sources — don't claim "No search results found". + if results.is_empty() && name == "x_search" { + return format!("Search provider: {provider_label}\nQuery: {query}\n"); + } + + crate::tools::websearch::format_results(provider_label, query, results, None) +} + +/// Hosted-search args that look populated but carry no usable query/sources. +pub(crate) fn hosted_search_args_are_hollow(args: &serde_json::Value) -> bool { + match args { + serde_json::Value::Null => true, + serde_json::Value::String(s) => { + let t = s.trim(); + t.is_empty() || t == "{}" + } + serde_json::Value::Object(map) if map.is_empty() => true, + serde_json::Value::Object(map) => { + let query_empty = map + .get("query") + .and_then(|v| v.as_str()) + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + let sources_empty = match map.get("sources") { + None => true, + Some(serde_json::Value::Array(a)) => a.is_empty(), + Some(serde_json::Value::Null) => true, + Some(_) => false, + }; + // Keep non-search keys (e.g. limit) from blocking hollow detection when + // the only useful fields (query/sources) are blank. + query_empty && sources_empty + } + _ => false, + } +} + +pub(crate) fn provider_tool_call_ui_events( + payload: &str, +) -> ( + Vec, + Option, +) { + let Ok(value) = serde_json::from_str::(payload) else { + return (Vec::new(), None); + }; + + let id = value + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("hosted_search") + .to_string(); + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("web_search") + .to_string(); + let status = value + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("running"); + + let arguments = match value.get("arguments") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => "{}".to_string(), + }; + + // Emit ToolCalls only while running so completed events don't duplicate cards. + // Completed/failed emit ToolResult (and a ToolCalls create if the running event + // was never seen — handled below by always including calls for first paint). + let calls = if status == "running" || status == "completed" || status == "failed" { + // Always include a ToolCalls create; add_tool_calls_to_session may duplicate + // if we already inserted — prefer upsert in app for hosted ids. + vec![crate::llm::ToolCall { + id: id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: name.clone(), + arguments: arguments.clone(), + }, + }] + } else { + Vec::new() + }; + + let result = if status == "completed" || status == "failed" { + let output_preview = hosted_search_output_preview(&name, status, &value); + // Use "ok" so the TUI shows output_preview (it gates on status == "ok"). + let payload = serde_json::json!({ + "status": if status == "failed" { "error" } else { "ok" }, + "provider_executed": true, + "output_preview": output_preview, + "title": name, + }); + Some(crate::llm::ToolCallResult { + tool_call_id: id, + role: "tool".to_string(), + name, + content: payload.to_string(), + }) + } else { + None + }; + + (calls, result) +} + fn tool_call_log_info(tool_call: &str) -> ToolCallLogInfo { let mut info = ToolCallLogInfo::default(); let Ok(value) = serde_json::from_str::(tool_call) else { @@ -469,6 +682,18 @@ pub async fn stream_llm_with_cancellation( if text_only_image_turn { aisdk_tools.retain(|tool| tool.name != "view_image"); } + if websearch_config.enabled.unwrap_or(true) { + let selection = crate::aisdk::providers::hosted_search::HostedSearchSelection { + web: websearch_config.native.web_enabled(), + x: websearch_config.native.x_enabled(), + }; + if selection.web || selection.x { + aisdk_tools.extend(crate::aisdk::providers::hosted_search::tools_for( + &request_config.provider_name, + selection, + )); + } + } let message_count = aisdk_messages.len(); let tool_count = aisdk_tools.len(); @@ -707,6 +932,7 @@ pub async fn summarize_for_compaction( } ChunkType::Reasoning(_) | ChunkType::ToolCall(_) + | ChunkType::ProviderToolCall(_) | ChunkType::End { .. } | ChunkType::AssistantMessagePhase { .. } | ChunkType::ResponseCompleted { .. } @@ -764,6 +990,7 @@ pub async fn generate_session_title( } ChunkType::Reasoning(_) | ChunkType::ToolCall(_) + | ChunkType::ProviderToolCall(_) | ChunkType::End { .. } | ChunkType::AssistantMessagePhase { .. } | ChunkType::ResponseCompleted { .. } @@ -924,6 +1151,8 @@ fn apply_provider_request_defaults( // Ask xAI not to persist Responses, including subagent requests. request_config.openai_options.force_store_false = true; } + + // Hosted search tools are appended to the tools list later (AI-SDK style). } fn maybe_apply_unauthenticated_free_provider_key( @@ -1605,6 +1834,23 @@ async fn relay_stream_to_sender( tool_call.len(), ); } + ChunkType::ProviderToolCall(payload) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("ProviderToolCall", elapsed_ms); + stats.tool_call_chunks += 1; + stats.tool_call_bytes += payload.len(); + crate::emit_log!( + "[RELAY] ProviderToolCall chunk received bytes={}", + payload.len() + ); + let (calls, result) = provider_tool_call_ui_events(&payload); + if !calls.is_empty() { + let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(calls)); + } + if let Some(result) = result { + let _ = sender.send(crate::llm::ChunkMessage::ToolResult(result)); + } + } ChunkType::End { reason } => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("End", elapsed_ms); @@ -1907,13 +2153,18 @@ fn append_assistant_parts_for_model( aisdk_messages.push(AisdkMessage::assistant(text)); } "tool_call" => { - if pending_tools.is_complete() { - pending_tools.flush_complete_pairs(aisdk_messages); - } - let Some(obj) = part.data.as_object() else { continue; }; + // Hosted search cards are display-only; do not replay as client + // function_call history (provider already executed them). + // Local websearch tool id is `websearch`; hosted names differ. + if is_provider_executed_tool_part(obj) { + continue; + } + if pending_tools.is_complete() { + pending_tools.flush_complete_pairs(aisdk_messages); + } if let Some(message) = tool_call_message_from_model_obj(obj) { if let Some(id) = part.tool_id() { pending_tools.add_call(id.to_string(), message); @@ -1924,6 +2175,9 @@ fn append_assistant_parts_for_model( let Some(obj) = part.data.as_object() else { continue; }; + if is_provider_executed_tool_part(obj) { + continue; + } let Some(id) = part.tool_id().map(str::to_string) else { continue; @@ -2232,7 +2486,9 @@ enum ProviderKind { impl ProviderKind { fn from_provider(_provider_name: &str, npm_package: &str) -> Self { match npm_package { - "@ai-sdk/openai-compatible" | "@ai-sdk/gateway" => Self::OpenAICompatible, + "@ai-sdk/openai-compatible" | "@ai-sdk/gateway" | "@openrouter/ai-sdk-provider" => { + Self::OpenAICompatible + } "@ai-sdk/anthropic" => Self::Anthropic, _ => Self::OpenAI, } @@ -2614,6 +2870,37 @@ mod tests { ); } + #[test] + fn openrouter_uses_openai_compatible_chat_completions() { + let provider: crate::model::discovery::Provider = + serde_json::from_value(serde_json::json!({ + "id": "openrouter", + "name": "OpenRouter", + "api": "https://openrouter.ai/api/v1", + "env": ["OPENROUTER_API_KEY"], + "npm": "@openrouter/ai-sdk-provider", + "models": { + "z-ai/glm-5.2:free": { + "id": "z-ai/glm-5.2:free", + "name": "GLM 5.2 Free" + } + } + })) + .unwrap(); + + let route = resolve_model_route(&provider, "z-ai/glm-5.2:free".to_string()); + assert_eq!(route.npm_package, "@openrouter/ai-sdk-provider"); + assert_eq!(route.api, "https://openrouter.ai/api/v1"); + assert_eq!( + ProviderKind::from_provider("openrouter", &route.npm_package), + ProviderKind::OpenAICompatible + ); + assert_eq!( + ProviderKind::OpenAICompatible.normalize_base_url(&route.api), + "https://openrouter.ai/api/v1" + ); + } + #[test] fn grok_composer_is_text_only_for_raw_image_transport() { let stale_image_model: crate::model::discovery::Model = @@ -3237,6 +3524,119 @@ mod tests { assert!(!rendered.iter().any(|c| c == "old user")); assert!(!rendered.iter().any(|c| c == "old assistant")); } + + #[test] + fn provider_tool_call_ui_events_emits_running_and_completed() { + let (calls, result) = super::provider_tool_call_ui_events( + r#"{"id":"xs_1","name":"x_search","status":"running","arguments":{"query":"carlo"}}"#, + ); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "xs_1"); + assert_eq!(calls[0].function.name, "x_search"); + assert!(result.is_none()); + + let (calls, result) = super::provider_tool_call_ui_events( + r#"{"id":"xs_1","name":"x_search","status":"completed","arguments":{"query":"carlo"}}"#, + ); + assert_eq!(calls.len(), 1); + let result = result.expect("completed should emit ToolResult"); + assert_eq!(result.tool_call_id, "xs_1"); + assert_eq!(result.name, "x_search"); + let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["provider_executed"], true); + // x_search with query only still shows provider + query header. + assert_eq!( + payload["output_preview"], + "Search provider: native (x)\nQuery: carlo\n" + ); + } + + #[test] + fn provider_tool_call_ui_events_web_search_preview_lists_sources() { + let payload = serde_json::json!({ + "id": "ws_1", + "name": "web_search", + "status": "completed", + "provider_executed": true, + "arguments": { + "type": "search", + "query": "carlo taleon", + "sources": [ + {"type": "url", "url": "https://carlo.tl/", "title": "Carlo"}, + {"type": "url", "url": "https://github.com/blankeos"} + ] + } + }); + let (_calls, result) = super::provider_tool_call_ui_events(&payload.to_string()); + let result = result.expect("completed web_search should emit ToolResult"); + let body: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(body["status"], "ok"); + let preview = body["output_preview"].as_str().unwrap(); + // Same formatter as local websearch (`format_results`). + assert_eq!( + preview, + crate::tools::websearch::format_results( + "native", + "carlo taleon", + vec![ + crate::tools::websearch::SearchItem { + title: "Carlo".into(), + url: "https://carlo.tl/".into(), + snippet: None, + date: None, + }, + crate::tools::websearch::SearchItem { + title: "https://github.com/blankeos".into(), + url: "https://github.com/blankeos".into(), + snippet: None, + date: None, + }, + ], + None, + ) + ); + } + + #[test] + fn hosted_search_args_are_hollow_detects_empty_query_sources() { + assert!(super::hosted_search_args_are_hollow(&serde_json::json!({ + "type": "search", + "query": "", + "sources": [] + }))); + assert!(!super::hosted_search_args_are_hollow(&serde_json::json!({ + "type": "search", + "query": "crabcode", + "sources": [] + }))); + } + + #[test] + fn convert_messages_skips_provider_executed_hosted_search_parts() { + let mut assistant = crate::session::types::Message::assistant(""); + assistant.add_tool_call_part("xs_1", "x_search", serde_json::json!({"query": "carlo"})); + if let Some(part) = assistant.parts.last_mut() { + if let Some(obj) = part.data.as_object_mut() { + obj.insert("provider_executed".into(), serde_json::Value::Bool(true)); + } + } + assistant.add_or_update_tool_result_part(serde_json::json!({ + "id": "xs_1", + "name": "x_search", + "status": "completed", + "provider_executed": true, + "output_preview": "done" + })); + + let messages = convert_messages(&[assistant]); + assert!( + messages + .iter() + .all(|m| !matches!(m, AisdkMessage::ToolCall(_) | AisdkMessage::ToolOutput(_))), + "hosted search parts must not replay into API history" + ); + } } fn content_with_vlm_agent_hint(content: &str, image_paths: &[String]) -> String { let paths = image_paths diff --git a/src/tools/websearch.rs b/src/tools/websearch.rs index 2c29af1a..9d841953 100644 --- a/src/tools/websearch.rs +++ b/src/tools/websearch.rs @@ -22,6 +22,8 @@ const DEFAULT_BRAVE_ENDPOINT: &str = "https://api.search.brave.com/res/v1/web/se const DEFAULT_OLLAMA_CLOUD_ENDPOINT: &str = "https://ollama.com/api/web_search"; const DEFAULT_SERPAPI_ENDPOINT: &str = "https://serpapi.com/search.json"; const DEFAULT_KEIRO_ENDPOINT: &str = "https://kierolabs.space/api/v2/keiro"; +const DEFAULT_PARALLEL_ENDPOINT: &str = "https://api.parallel.ai/v1/search"; +const DEFAULT_TAKO_ENDPOINT: &str = "https://tako.com/api/v3/search"; const DEFAULT_TIMEOUT_SECS: u64 = 25; const MAX_RESPONSE_BYTES: usize = 512 * 1024; const DEFAULT_NUM_RESULTS: i64 = 8; @@ -42,8 +44,14 @@ impl WebsearchTool { } } - pub fn is_enabled_for_provider(_provider_name: &str, config: &WebsearchConfig) -> bool { - config.enabled.unwrap_or(true) + pub fn is_enabled_for_provider(provider_name: &str, config: &WebsearchConfig) -> bool { + if !config.enabled.unwrap_or(true) { + return false; + } + crate::aisdk::providers::should_register_local_websearch( + provider_name, + config.native.web_enabled(), + ) } fn adapter(&self) -> Box { @@ -75,6 +83,12 @@ impl WebsearchTool { WebsearchProvider::Keiro => Box::new(KeiroAdapter { config: &self.config, }), + WebsearchProvider::Parallel => Box::new(ParallelAdapter { + config: &self.config, + }), + WebsearchProvider::Tako => Box::new(TakoAdapter { + config: &self.config, + }), } } } @@ -198,11 +212,11 @@ struct WebsearchInput { } #[derive(Debug, Clone, PartialEq, Eq)] -struct SearchItem { - title: String, - url: String, - snippet: Option, - date: Option, +pub(crate) struct SearchItem { + pub title: String, + pub url: String, + pub snippet: Option, + pub date: Option, } #[async_trait] @@ -245,6 +259,14 @@ struct KeiroAdapter<'a> { config: &'a WebsearchConfig, } +struct ParallelAdapter<'a> { + config: &'a WebsearchConfig, +} + +struct TakoAdapter<'a> { + config: &'a WebsearchConfig, +} + #[async_trait] impl WebsearchAdapter for ExaHostedMcpAdapter<'_> { fn provider_name(&self) -> &'static str { @@ -594,6 +616,89 @@ impl WebsearchAdapter for KeiroAdapter<'_> { } } +#[async_trait] +impl WebsearchAdapter for ParallelAdapter<'_> { + fn provider_name(&self) -> &'static str { + "parallel" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "PARALLEL_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_PARALLEL_ENDPOINT); + let mode = match input.search_type.as_str() { + "fast" => "turbo", + "deep" => "advanced", + _ => "fast", + }; + let request = client + .post(&endpoint) + .header("x-api-key", api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "objective": input.query, + "search_queries": [input.query], + "mode": mode, + "max_results": input.num_results, + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_parallel_results(&value), + None, + )) + } +} + +#[async_trait] +impl WebsearchAdapter for TakoAdapter<'_> { + fn provider_name(&self) -> &'static str { + "tako" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "TAKO_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_TAKO_ENDPOINT); + let effort = match input.search_type.as_str() { + "fast" => "instant", + "deep" => "deep", + _ => "fast", + }; + let count = input.num_results.clamp(1, 20); + let request = client + .post(&endpoint) + .header("X-API-Key", api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "effort": effort, + "sources": { + "data": { "count": count }, + "web": { "count": count } + } + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_tako_results(&value), + None, + )) + } +} + fn endpoint_or(configured: &Option, default: &str) -> String { configured.clone().unwrap_or_else(|| default.to_string()) } @@ -937,6 +1042,79 @@ fn parse_keiro_results(value: &Value) -> Vec { parse_standard_results(value, &["snippet", "content", "text", "description"]) } +fn parse_parallel_results(value: &Value) -> Vec { + value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "url")?; + let snippet = item + .get("excerpts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .find(|excerpt| !excerpt.trim().is_empty()) + .map(|excerpt| clean_snippet(excerpt)); + let date = string_field(item, "publish_date"); + Some(SearchItem { + title, + url, + snippet, + date, + }) + }) + .collect() +} + +fn parse_tako_results(value: &Value) -> Vec { + let mut results = Vec::new(); + + if let Some(cards) = value.get("cards").and_then(Value::as_array) { + for card in cards { + let Some(title) = string_field(card, "title") else { + continue; + }; + let Some(url) = string_field(card, "webpage_url") + .or_else(|| string_field(card, "embed_url")) + .or_else(|| string_field(card, "url")) + else { + continue; + }; + results.push(SearchItem { + title, + url, + snippet: string_field(card, "description").map(|value| clean_snippet(&value)), + date: None, + }); + } + } + + if let Some(web) = value.get("web_results").and_then(Value::as_array) { + for item in web { + let Some(title) = string_field(item, "title") else { + continue; + }; + let Some(url) = string_field(item, "url") else { + continue; + }; + results.push(SearchItem { + title, + url, + snippet: string_field(item, "snippet") + .or_else(|| string_field(item, "content")) + .map(|value| clean_snippet(&value)), + date: string_field(item, "publish_date"), + }); + } + } + + results +} + fn parse_standard_results(value: &Value, snippet_keys: &[&str]) -> Vec { value .get("results") @@ -960,7 +1138,7 @@ fn parse_standard_results(value: &Value, snippet_keys: &[&str]) -> Vec, @@ -1105,6 +1283,61 @@ mod tests { ); } + #[test] + fn parses_parallel_results() { + let value = json!({ + "results": [{ + "url": "https://example.com/parallel", + "title": "Parallel Result", + "publish_date": "2025-11-19", + "excerpts": ["First excerpt", "Second excerpt"] + }] + }); + assert_eq!( + parse_parallel_results(&value), + vec![SearchItem { + title: "Parallel Result".to_string(), + url: "https://example.com/parallel".to_string(), + snippet: Some("First excerpt".to_string()), + date: Some("2025-11-19".to_string()), + }] + ); + } + + #[test] + fn parses_tako_results() { + let value = json!({ + "cards": [{ + "title": "Silver Spot Price", + "description": "Spot price of silver", + "webpage_url": "https://tako.com/card/abc/" + }], + "web_results": [{ + "title": "Web Hit", + "url": "https://example.com/web", + "snippet": "A web snippet", + "publish_date": "2026-01-02" + }] + }); + assert_eq!( + parse_tako_results(&value), + vec![ + SearchItem { + title: "Silver Spot Price".to_string(), + url: "https://tako.com/card/abc/".to_string(), + snippet: Some("Spot price of silver".to_string()), + date: None, + }, + SearchItem { + title: "Web Hit".to_string(), + url: "https://example.com/web".to_string(), + snippet: Some("A web snippet".to_string()), + date: Some("2026-01-02".to_string()), + }, + ] + ); + } + #[test] fn parses_exa_mcp_text_blocks() { let text = "\ @@ -1236,16 +1469,39 @@ Useful second snippet #[test] fn enabled_by_default_but_config_can_disable() { + // Default keeps local websearch even on providers with hosted web tools. + assert!(WebsearchTool::is_enabled_for_provider( + "ollama", + &WebsearchConfig::default() + )); assert!(WebsearchTool::is_enabled_for_provider( "openai", &WebsearchConfig::default() )); + assert!(WebsearchTool::is_enabled_for_provider( + "xai", + &WebsearchConfig::default() + )); let mut disabled = WebsearchConfig::default(); disabled.enabled = Some(false); assert!(!WebsearchTool::is_enabled_for_provider( "opencode", &disabled )); + + // native.web true skips local on supported providers + let mut prefer_native_web = WebsearchConfig::default(); + prefer_native_web.native.web = Some(true); + assert!(!WebsearchTool::is_enabled_for_provider( + "openai", + &prefer_native_web + )); + + // native.x alone must not displace local websearch + let mut x_only = WebsearchConfig::default(); + x_only.native.web = Some(false); + x_only.native.x = Some(true); + assert!(WebsearchTool::is_enabled_for_provider("xai", &x_only)); } #[test] diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 90681bea..5ea3e46b 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -52,13 +52,31 @@ fn assistant_tool_part_info( Some(info) } "tool_result" => { - let mut info = parsed_tool_message_from_object(part.data.as_object()?, false); - if info.args.is_none() { - info.args = part - .tool_id() - .and_then(|id| message.tool_call_part_data(id)) - .and_then(|call| call.get("args")) - .cloned(); + // Show output_preview for ok/completed hosted-search cards. + let status = part + .data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("ok"); + let include_preview = + status.eq_ignore_ascii_case("ok") || status.eq_ignore_ascii_case("completed"); + let mut info = parsed_tool_message_from_object(part.data.as_object()?, include_preview); + let call_args = part + .tool_id() + .and_then(|id| message.tool_call_part_data(id)) + .and_then(|call| call.get("args")) + .cloned(); + let result_args_hollow = info + .args + .as_ref() + .map(crate::llm::client::hosted_search_args_are_hollow) + .unwrap_or(true); + if info.args.is_none() || result_args_hollow { + if let Some(args) = + call_args.filter(|a| !crate::llm::client::hosted_search_args_are_hollow(a)) + { + info.args = Some(args); + } } Some(info) }