F5: web search/fetch on the Kimi services (kimi-cli parity)

web_search now speaks the Kimi search service (kimi-cli tools/web/search.py,
wire-verified against api.kimi.com):
- POST {coding_base}/search with {text_query, limit 1-20 (default 5),
  enable_page_crawling, timeout_seconds: 30}, OAuth bearer +
  X-Msh-Tool-Call-Id; results render in kimi-cli's Title/Date/URL/Summary
  schema with result URLs as citations.
- The old implementation called the xAI Responses API with a search model;
  that client is fully replaced and the entire model-based config surface
  is excised root-and-branch: web_search_model config keys/env/CLI plumbing,
  resolve_web_search_sampling_config, toolset web_search SamplerConfig,
  RemoteSettings.web_search_model, default_web_search_model.
- Enablement is now purely structural: the service exists only on the Kimi
  Code subscription channel, so OAuth sessions get Enabled and API-key-only
  sessions get Disabled (tool absent) — per PRD F5.

web_fetch gains the Kimi fetch service as its primary path (kimi-cli
tools/web/fetch.py): POST {coding_base}/fetch with {url}, Accept:
text/markdown, OAuth bearer + X-Msh-Tool-Call-Id; the 200 body is the
extracted markdown (still overflow-budgeted). Any service failure falls
back to the existing local pipeline (SSRF guards, cache, extraction).
The tool gate defaults ON now (kimi-cli always offers FetchURL) and the
egress User-Agent no longer claims grok-agent/x.ai.

Verified end-to-end against the scripted mock service: a headless session
drove web_search (limit/crawling/call-id observed on the wire) then
web_fetch (Accept + call-id observed) to completion.
This commit is contained in:
2026-07-17 19:33:19 -04:00
parent 78cd94a751
commit 74b210535e
34 changed files with 659 additions and 1209 deletions
@@ -30,6 +30,8 @@ pub struct WebFetchClient {
image_writer: SessionFileWriter,
video_writer: SessionFileWriter,
overflow: OverflowHandler,
/// Live-token source for the Kimi fetch service (OAuth refresh).
api_key_provider: Option<crate::types::SharedApiKeyProvider>,
}
struct ProcessedText {
@@ -42,7 +44,10 @@ struct ProcessedText {
}
impl WebFetchClient {
pub fn new(params: &WebFetchParams) -> Result<Self, WebFetchError> {
pub fn new(
params: &WebFetchParams,
api_key_provider: Option<crate::types::SharedApiKeyProvider>,
) -> Result<Self, WebFetchError> {
let converter = Arc::new(
htmd::HtmlToMarkdown::builder()
.skip_tags(vec![
@@ -64,9 +69,67 @@ impl WebFetchClient {
image_writer: SessionFileWriter::new("images", "jpg"),
video_writer: SessionFileWriter::new("videos", "mp4"),
overflow: OverflowHandler::new(),
api_key_provider,
})
}
/// Fetch through the Kimi fetch service (kimi-cli `_fetch_with_service`):
/// `POST {service_url}` with `{"url": ...}`, `Accept: text/markdown`,
/// OAuth bearer, and `X-Msh-Tool-Call-Id`. The 200 body IS the extracted
/// markdown; it still runs through the overflow budget so a huge page
/// cannot flood the context.
async fn fetch_via_service(
&self,
service_url: &str,
url_str: &str,
tool_call_id: &str,
session_folder: Option<&Path>,
tools: RecoveryTools<'_>,
) -> Result<WebFetchOutput, WebFetchError> {
let Some(bearer) =
crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref()).await
else {
return Err(WebFetchError::ServiceUnavailable(
"no live bearer token for the fetch service".to_string(),
));
};
let http = self.http.get_or_rebuild()?;
let response = http
.post(service_url)
.header("Authorization", format!("Bearer {bearer}"))
.header("Accept", "text/markdown")
.header("X-Msh-Tool-Call-Id", tool_call_id)
.json(&serde_json::json!({ "url": url_str }))
.send()
.await
.map_err(|e| WebFetchError::ServiceUnavailable(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(WebFetchError::ServiceUnavailable(format!(
"fetch service returned {status}"
)));
}
let body = response
.text()
.await
.map_err(|e| WebFetchError::ServiceUnavailable(e.to_string()))?;
let processed = self
.process_text_content(body.as_bytes(), "text/markdown", session_folder, tools)
.await;
Ok(WebFetchOutput::Content(WebFetchContent {
url: url_str.to_string(),
content: processed.content,
content_type: processed.content_type,
status_code: status.as_u16(),
bytes: processed.bytes,
source_artifact: processed
.artifact_path
.map(|path| WebFetchSourceArtifact { path }),
inline_fallback: processed.inline_fallback,
output_location: None,
}))
}
/// Fetch a URL and return its content as markdown.
///
/// Handles: validation, HTTPS upgrade, SSRF check, HTTP fetch with
@@ -76,6 +139,7 @@ impl WebFetchClient {
pub async fn fetch(
&self,
raw_url: &str,
tool_call_id: &str,
session_folder: Option<&Path>,
read_tool_name: Option<&str>,
execute_tool_name: Option<&str>,
@@ -94,6 +158,33 @@ impl WebFetchClient {
}
}
// Kimi fetch service first (OAuth sessions); local pipeline is the
// fallback on any service failure (kimi-cli fetch.py `__call__`).
if let Some(service_url) = self.params.service_url.clone() {
match self
.fetch_via_service(
&service_url,
&url_str,
tool_call_id,
session_folder,
RecoveryTools {
read: read_tool_name,
execute: execute_tool_name,
},
)
.await
{
Ok(output) => {
let mut cache = self.cache.write();
cache.insert_text(url_str, output.clone(), false);
return Ok(output);
}
Err(e) => {
tracing::warn!(error = %e, url = %url_str, "Kimi fetch service failed; falling back to local fetch");
}
}
}
// SSRF check.
ssrf::check_ssrf(&url).await?;
@@ -817,6 +908,96 @@ fn strip_base64_data_uris(content: String) -> String {
mod tests {
use super::*;
/// Kimi fetch service happy path (kimi-cli `_fetch_with_service`):
/// the POST carries the OAuth bearer + call id + Accept: text/markdown,
/// and the 200 body IS the page markdown.
#[tokio::test]
async fn service_fetch_posts_kimi_contract_and_returns_markdown() {
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/fetch"))
.and(header("accept", "text/markdown"))
.and(header("authorization", "Bearer live-token"))
.and(header("x-msh-tool-call-id", "call-7"))
.and(body_json(
serde_json::json!({ "url": "https://docs.rs/serde" }),
))
.respond_with(ResponseTemplate::new(200).set_body_string("# Serde\n\nExtracted."))
.expect(1)
.mount(&server)
.await;
let params = WebFetchParams {
service_url: Some(format!("{}/fetch", server.uri())),
..WebFetchParams::default()
};
let provider = crate::types::api_key_provider::test_support::fixed_provider("live-token");
let client = WebFetchClient::new(&params, Some(provider)).unwrap();
let output = client
.fetch_via_service(
&params.service_url.clone().unwrap(),
"https://docs.rs/serde",
"call-7",
None,
RecoveryTools {
read: None,
execute: None,
},
)
.await
.unwrap();
match output {
WebFetchOutput::Content(content) => {
assert_eq!(content.url, "https://docs.rs/serde");
assert!(content.content.contains("# Serde"));
assert_eq!(content.status_code, 200);
}
other => panic!("expected Content, got {other:?}"),
}
}
/// A failing service must yield an error the caller can fall back on —
/// never a fabricated success.
#[tokio::test]
async fn service_fetch_errors_on_non_200_and_missing_token() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/fetch"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let url = format!("{}/fetch", server.uri());
let tools = || RecoveryTools {
read: None,
execute: None,
};
// 503 from the service → ServiceUnavailable.
let provider = crate::types::api_key_provider::test_support::fixed_provider("t");
let params = WebFetchParams {
service_url: Some(url.clone()),
..WebFetchParams::default()
};
let client = WebFetchClient::new(&params, Some(provider)).unwrap();
let err = client
.fetch_via_service(&url, "https://docs.rs/x", "c", None, tools())
.await
.unwrap_err();
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
// No bearer available → ServiceUnavailable without any HTTP call.
let client = WebFetchClient::new(&params, None).unwrap();
let err = client
.fetch_via_service(&url, "https://docs.rs/x", "c", None, tools())
.await
.unwrap_err();
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
}
fn test_converter() -> htmd::HtmlToMarkdown {
htmd::HtmlToMarkdown::builder()
.skip_tags(vec![
@@ -828,10 +1009,13 @@ mod tests {
#[tokio::test]
async fn oversized_html_persists_exact_pre_truncation_markdown() {
let tmp = tempfile::tempdir().unwrap();
let client = WebFetchClient::new(&WebFetchParams {
context_window_tokens: Some(100),
..WebFetchParams::default()
})
let client = WebFetchClient::new(
&WebFetchParams {
context_window_tokens: Some(100),
..WebFetchParams::default()
},
None,
)
.unwrap();
let tail = "TAIL-MUST-REMAIN-RECOVERABLE";
let html = format!(
@@ -1291,7 +1475,7 @@ mod tests {
);
// Client builds successfully with the proxy endpoint set.
let client = WebFetchClient::new(&params);
let client = WebFetchClient::new(&params, None);
assert!(client.is_ok());
}
@@ -9,7 +9,8 @@ use crate::register_resource;
// Safety-boundary constants. Not configurable.
pub const MAX_URL_LENGTH: usize = 2_000;
pub const MAX_REDIRECTS: usize = 10;
pub const USER_AGENT_STRING: &str = "Mozilla/5.0 (compatible; grok-agent/1.0; +https://x.ai)";
pub const USER_AGENT_STRING: &str =
"Mozilla/5.0 (compatible; kigi-agent/1.0; +https://github.com/ZacharyZhang-NY/Kigi-CLI)";
/// Runtime-configurable parameters for the `web_fetch` tool.
///
@@ -40,6 +41,12 @@ pub struct WebFetchParams {
/// routed through this URL.
#[serde(default)]
pub proxy_endpoint: Option<String>,
/// Kimi fetch service endpoint (`POST {coding_base}/fetch`, PRD F5).
/// Set by the shell for Kimi Code OAuth sessions; when present, the
/// tool tries the service first and falls back to the local pipeline
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
#[serde(default)]
pub service_url: Option<String>,
}
register_resource!("grok_build", "WebFetch", WebFetchParams);
@@ -18,6 +18,9 @@ pub enum WebFetchError {
#[error("invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("fetch service unavailable: {0}")]
ServiceUnavailable(String),
#[error("SSRF blocked: {host} resolves to private/internal IP {ip}{}", ssrf_recovery_hint(.host))]
SsrfBlocked { host: String, ip: IpAddr },
@@ -184,6 +184,7 @@ impl kigi_tool_runtime::Tool for WebFetchTool {
let output = client
.fetch(
&input.url,
ctx.call_id.as_str(),
session_folder.as_deref(),
read_tool_name.as_deref(),
execute_tool_name.as_deref(),
@@ -1,8 +1,9 @@
//! `web_search` tool — new architecture (`Tool` trait).
//!
//! Calls the Responses API with web search capability. Reads the
//! pre-constructed `WebSearchClient` from Resources (inserted by
//! `with_backend()` when the config is `Enabled`).
//! Calls the Kimi search service (PRD F5; kimi-cli `tools/web/search.py`
//! parity). Reads the pre-constructed `WebSearchClient` from Resources
//! (inserted by `with_backend()` when the config is `Enabled`, i.e. only
//! on Kimi Code OAuth sessions).
use crate::implementations::web_search::client::WebSearchClient;
use crate::types::output::WebSearchOutput;
@@ -15,12 +16,27 @@ use crate::types::tool::{ToolKind, ToolNamespace};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct WebSearchInput {
#[schemars(description = "The search query to perform.")]
#[schemars(description = "The query text to search for.")]
pub query: String,
#[schemars(description = "Optional list of domains to restrict search to.")]
pub allowed_domains: Option<Vec<String>>,
#[schemars(
description = "The number of results to return (1-20). Typically you do \
not need to set this value. When the results do not contain \
what you need, you probably want to give a more concrete \
query."
)]
pub limit: Option<u8>,
#[schemars(
description = "Whether to include the content of the web pages in the \
results. It can consume a large amount of tokens when set. \
Avoid enabling this together with a large limit."
)]
pub include_content: Option<bool>,
}
/// kimi-cli search.py `Params.limit` default / bounds (default=5, ge=1, le=20).
const DEFAULT_LIMIT: u8 = 5;
const MAX_LIMIT: u8 = 20;
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
@@ -87,21 +103,21 @@ impl kigi_tool_runtime::Tool for WebSearchTool {
client = res.require::<WebSearchClient>()?.clone();
}
let limit = input.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let (content, citations) = client
.search(&input.query, input.allowed_domains.clone())
.await
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
e.to_string(),
)
})?;
.search(
&input.query,
limit,
input.include_content.unwrap_or(false),
ctx.call_id.as_str(),
)
.await?;
Ok(WebSearchOutput {
query: input.query.clone(),
content,
citations,
allowed_domains: input.allowed_domains.clone(),
allowed_domains: None,
pre_formatted: None,
})
}
@@ -136,7 +152,8 @@ mod tests {
test_ctx_with_call_id(resources.into_shared(), "test-call"),
WebSearchInput {
query: "test".into(),
allowed_domains: None,
limit: None,
include_content: None,
},
)
.await;
@@ -1,21 +1,70 @@
//! HTTP client for the Kimi search service (PRD F5).
//!
//! Wire contract ported from kimi-cli `tools/web/search.py` (`SearchWeb`)
//! and verified against the live `api.kimi.com/coding/v1` service:
//!
//! - `POST {search_url}` with JSON `{"text_query", "limit",
//! "enable_page_crawling", "timeout_seconds": 30}`
//! - headers: `Authorization: Bearer <token>` and
//! `X-Msh-Tool-Call-Id: <tool call id>` (search.py:82-88)
//! - 200 → `{"search_results": [{site_name, title, url, snippet,
//! content?, date?, icon?, mime?}]}`
//!
//! The server-side timeout is 30s but page crawling can run longer, so the
//! client allows a generous total timeout (search.py:74 uses 180s).
use super::types::WebSearchConfig;
use crate::attribution::{SharedAttributionCallback, ToolConsumer};
use crate::types::SharedApiKeyProvider;
use async_openai::types::responses as rs;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
/// A minimal, purpose-built HTTP client for calling the Responses API
/// with web search capability.
/// Total request timeout. Mirrors kimi-cli search.py:74 (`total=180`):
/// the service crawls pages when `include_content` is set.
const SEARCH_TIMEOUT_SECS: u64 = 180;
/// `timeout_seconds` request field — the server-side search budget
/// (search.py:93).
const SERVER_TIMEOUT_SECS: u64 = 30;
fn tool_error(msg: impl Into<String>) -> kigi_tool_runtime::ToolError {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
msg.into(),
)
}
/// One search hit (kimi-cli search.py `SearchResult`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SearchResult {
#[serde(default)]
pub site_name: String,
pub title: String,
pub url: String,
pub snippet: String,
#[serde(default)]
pub content: String,
#[serde(default)]
pub date: String,
}
/// Response envelope (kimi-cli search.py `Response`).
#[derive(Debug, serde::Deserialize)]
struct SearchResponse {
search_results: Vec<SearchResult>,
}
/// A minimal, purpose-built HTTP client for the Kimi search service.
#[derive(Clone)]
pub struct WebSearchClient {
http: reqwest::Client,
base_url: String,
model: String,
search_url: String,
api_key: String,
api_key_provider: Option<SharedApiKeyProvider>,
/// Optional 401-attribution hook. Callers can wire this so a 401
/// from the Responses API emits an `auth_401_attribution` event
/// with `consumer == "WebSearch"`.
/// Optional 401-attribution hook. Callers can wire this so a 401 from
/// the search service emits an `auth_401_attribution` event with
/// `consumer == "WebSearch"`.
attribution_callback: Option<SharedAttributionCallback>,
}
impl WebSearchClient {
/// Create a new web search client from `WebSearchConfig::Enabled`.
///
@@ -25,62 +74,38 @@ impl WebSearchClient {
api_key_provider: Option<SharedApiKeyProvider>,
) -> Result<Self, kigi_tool_runtime::ToolError> {
let WebSearchConfig::Enabled {
search_url,
api_key,
base_url,
model,
extra_headers,
alpha_test_key,
} = config
else {
return Err(kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
"Cannot create WebSearchClient from disabled config".to_string(),
return Err(tool_error(
"Cannot create WebSearchClient from disabled config",
));
};
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Invalid API key for header: {e}"),
)
})?,
);
for (key, value) in extra_headers {
let header_name = HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Invalid header name '{key}': {e}"),
)
})?;
let header_value = HeaderValue::from_str(value).map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Invalid header value for '{key}': {e}"),
)
})?;
let header_name = HeaderName::from_bytes(key.as_bytes())
.map_err(|e| tool_error(format!("Invalid header name '{key}': {e}")))?;
let header_value = HeaderValue::from_str(value)
.map_err(|e| tool_error(format!("Invalid header value for '{key}': {e}")))?;
headers.insert(header_name, header_value);
}
let _ = alpha_test_key;
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(SEARCH_TIMEOUT_SECS))
.build()
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build HTTP client: {e}"),
)
})?;
.map_err(|e| tool_error(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.clone(),
model: model.clone(),
search_url: search_url.clone(),
api_key: api_key.clone(),
api_key_provider,
attribution_callback: None,
})
}
/// Wire a 401-attribution callback into this client. Idempotent;
/// safe to call before or after the first request.
pub fn with_attribution_callback(
@@ -90,501 +115,255 @@ impl WebSearchClient {
self.attribution_callback = callback;
self
}
async fn current_bearer(&self) -> Option<String> {
crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref()).await
/// Live token from the provider (OAuth refresh) when available, else the
/// config-time key.
async fn current_bearer(&self) -> String {
crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref())
.await
.unwrap_or_else(|| self.api_key.clone())
}
fn record_401_attribution(&self, sent_bearer: Option<&str>) {
fn record_401_attribution(&self, sent_bearer: &str) {
crate::attribution::emit_401(
self.attribution_callback.as_ref(),
ToolConsumer::WebSearch,
sent_bearer,
Some(sent_bearer),
);
}
/// Perform a web search query using the Responses API.
/// Search the Kimi service. Returns the rendered result text plus the
/// unique result URLs as citations.
///
/// Returns `(content, citations)` where content is the assistant's text
/// and citations are unique URLs found in the response annotations.
/// `tool_call_id` rides along as `X-Msh-Tool-Call-Id` (search.py:85) so
/// the service can correlate the request with the agent turn.
pub async fn search(
&self,
query: &str,
allowed_domains: Option<Vec<String>>,
limit: u8,
include_content: bool,
tool_call_id: &str,
) -> Result<(String, Vec<String>), kigi_tool_runtime::ToolError> {
let web_search = rs::WebSearchToolArgs::default()
.filters(rs::WebSearchToolFilters { allowed_domains })
.build()
let bearer = self.current_bearer().await;
let response = self
.http
.post(&self.search_url)
.header(AUTHORIZATION, format!("Bearer {bearer}"))
.header("X-Msh-Tool-Call-Id", tool_call_id)
.json(&serde_json::json!({
"text_query": query,
"limit": limit,
"enable_page_crawling": include_content,
"timeout_seconds": SERVER_TIMEOUT_SECS,
}))
.send()
.await
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build web search tool: {e}"),
)
tool_error(format!(
"Search request failed: {e}. The search service may be unavailable."
))
})?;
let request = rs::CreateResponseArgs::default()
.model(self.model.clone())
.input(query.to_string())
.tools(vec![rs::Tool::WebSearch(web_search)])
.store(false)
.temperature(0.1_f32)
.top_p(0.95_f32)
.max_output_tokens(8192u32)
.build()
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build request: {e}"),
)
})?;
let url = format!("{}/responses", self.base_url.trim_end_matches('/'));
let sent_bearer = self.current_bearer().await;
let mut req = self.http.post(&url).json(&request);
if let Some(ref key) = sent_bearer {
req = req.header(AUTHORIZATION, format!("Bearer {key}"));
}
let response = req.send().await.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("HTTP request failed: {e}"),
)
})?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
self.record_401_attribution(sent_bearer.as_deref());
let body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
return Err(kigi_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
}
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
return Err(kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Responses API returned {status}: {body}"),
self.record_401_attribution(&bearer);
return Err(kigi_tool_runtime::ToolError::unauthorized(
"Search service returned 401 Unauthorized".to_string(),
));
}
let bytes = response.bytes().await.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to read response body: {e}"),
)
})?;
let response_obj: rs::Response = serde_json::from_slice(&bytes).map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to parse response: {e}"),
)
})?;
let content = response_obj
.output_text()
.unwrap_or_else(|| "No search results found.".to_string());
let citations = extract_citations(&response_obj);
Ok((content, citations))
}
/// Same as [`Self::search`] but also extracts per-citation titles when
/// the Responses API surfaces them. Returns `(content, citations_with_titles)`
/// where each citation is `(title, url)`. Empty `title` strings indicate
/// the upstream didn't supply one for that URL.
///
/// Used by the cursor-compat `WebSearch` adapter to render a
/// `Links:\n1. [title](url)` list instead of the LLM synthesis text.
pub async fn search_with_titles(
&self,
query: &str,
allowed_domains: Option<Vec<String>>,
) -> Result<(String, Vec<(String, String)>), kigi_tool_runtime::ToolError> {
let web_search = rs::WebSearchToolArgs::default()
.filters(rs::WebSearchToolFilters { allowed_domains })
.build()
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build web search tool: {e}"),
)
})?;
let request = rs::CreateResponseArgs::default()
.model(self.model.clone())
.input(query.to_string())
.tools(vec![rs::Tool::WebSearch(web_search)])
.store(false)
.temperature(0.1_f32)
.top_p(0.95_f32)
.max_output_tokens(8192u32)
.build()
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build request: {e}"),
)
})?;
let url = format!("{}/responses", self.base_url.trim_end_matches('/'));
let sent_bearer = self.current_bearer().await;
let mut req = self.http.post(&url).json(&request);
if let Some(ref key) = sent_bearer {
req = req.header(AUTHORIZATION, format!("Bearer {key}"));
}
let response = req.send().await.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("HTTP request failed: {e}"),
)
})?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
self.record_401_attribution(sent_bearer.as_deref());
let body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
return Err(kigi_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
}
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
return Err(kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Responses API returned {status}: {body}"),
));
return Err(tool_error(format!(
"Failed to search. Status: {status}. This may indicate that the \
search service is currently unavailable."
)));
}
let bytes = response.bytes().await.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to read response body: {e}"),
)
})?;
let response_obj: rs::Response = serde_json::from_slice(&bytes).map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to parse response: {e}"),
)
})?;
let content = response_obj
.output_text()
.unwrap_or_else(|| "No search results found.".to_string());
let pairs = extract_citation_pairs(&response_obj);
Ok((content, pairs))
let results = response
.json::<SearchResponse>()
.await
.map_err(|e| tool_error(format!("Failed to parse search results: {e}")))?
.search_results;
Ok(render_results(&results))
}
}
/// Extract citation URLs from the Response output items.
/// The async-openai crate doesn't provide a helper for this, and the `url` field
/// in `UrlCitationBody` is private, so we serialize to JSON to extract it.
fn extract_citations(response: &rs::Response) -> Vec<String> {
let mut citations = Vec::new();
for output_item in &response.output {
if let rs::OutputItem::Message(output_message) = output_item {
for message_content in &output_message.content {
if let rs::OutputMessageContent::OutputText(text_content) = message_content {
for annotation in &text_content.annotations {
if let rs::Annotation::UrlCitation(url_citation) = annotation
&& let Ok(json) = serde_json::to_value(url_citation)
&& let Some(url) = json.get("url").and_then(|v| v.as_str())
{
citations.push(url.to_string());
}
}
}
}
/// Render hits in kimi-cli's result schema (search.py:141-149):
/// `Title/Date/URL/Summary` per hit, page content when crawled, hits
/// separated by `---`. Citations are the unique result URLs in order.
fn render_results(results: &[SearchResult]) -> (String, Vec<String>) {
let mut content = String::new();
let mut citations: Vec<String> = Vec::new();
for (i, result) in results.iter().enumerate() {
if i > 0 {
content.push_str("---\n\n");
}
content.push_str(&format!(
"Title: {}\nDate: {}\nURL: {}\nSummary: {}\n\n",
result.title, result.date, result.url, result.snippet
));
if !result.content.is_empty() {
content.push_str(&format!("{}\n\n", result.content));
}
if !citations.contains(&result.url) {
citations.push(result.url.clone());
}
}
let mut seen = std::collections::HashSet::new();
citations.retain(|url| seen.insert(url.clone()));
citations
}
/// Extract `(title, url)` pairs from the Responses API annotations.
///
/// `title` may be an empty string when upstream doesn't supply one. URLs
/// are deduplicated while preserving the first-seen order so the rendered
/// `Links:` list is stable and free of duplicates.
fn extract_citation_pairs(response: &rs::Response) -> Vec<(String, String)> {
let mut pairs: Vec<(String, String)> = Vec::new();
for output_item in &response.output {
if let rs::OutputItem::Message(output_message) = output_item {
for message_content in &output_message.content {
if let rs::OutputMessageContent::OutputText(text_content) = message_content {
for annotation in &text_content.annotations {
if let rs::Annotation::UrlCitation(url_citation) = annotation
&& let Ok(json) = serde_json::to_value(url_citation)
{
let url = json.get("url").and_then(|v| v.as_str()).unwrap_or("");
if url.is_empty() {
continue;
}
let title = json
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
pairs.push((title, url.to_string()));
}
}
}
}
}
}
let mut seen = std::collections::HashSet::new();
pairs.retain(|(_t, url)| seen.insert(url.clone()));
pairs
(content, citations)
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
/// Helper to create a Response from JSON for testing.
fn response_from_json(json: serde_json::Value) -> rs::Response {
serde_json::from_value(json).expect("Failed to parse test Response JSON")
}
#[test]
fn test_new_client_uses_configured_model() {
let config = WebSearchConfig::Enabled {
fn enabled_config(url: &str) -> WebSearchConfig {
WebSearchConfig::Enabled {
search_url: url.to_string(),
api_key: "test-key".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "custom-enterprise-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
};
let client = WebSearchClient::new(&config, None).expect("client should build");
assert_eq!(client.model, "custom-enterprise-model");
}
/// Counts attribution callback invocations for the test below.
#[derive(Default, Debug)]
struct CountingCallback {
invocations: std::sync::Mutex<Vec<(ToolConsumer, Option<String>)>>,
}
impl crate::attribution::Auth401AttributionCallback for CountingCallback {
fn record_401(&self, consumer: ToolConsumer, sent_bearer_prefix: Option<&str>) {
self.invocations
.lock()
.unwrap()
.push((consumer, sent_bearer_prefix.map(|s| s.to_string())));
}
}
/// `record_401_attribution` invokes the wired callback with
/// `ToolConsumer::WebSearch` and the truncated bearer prefix.
/// The full bearer never crosses the trait boundary.
#[test]
fn record_401_attribution_passes_truncated_prefix_to_callback() {
let cb = std::sync::Arc::new(CountingCallback::default());
let cb_dyn: crate::attribution::SharedAttributionCallback = cb.clone();
fn new_rejects_disabled_config() {
assert!(WebSearchClient::new(&WebSearchConfig::Disabled, None).is_err());
}
#[test]
fn new_rejects_invalid_extra_header() {
let mut headers = IndexMap::new();
headers.insert("bad header name".to_string(), "v".to_string());
let config = WebSearchConfig::Enabled {
api_key: "ignored".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "test-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
api_key: "k".to_string(),
extra_headers: headers,
};
let client = WebSearchClient::new(&config, None)
.expect("client should build")
.with_attribution_callback(Some(cb_dyn));
client.record_401_attribution(Some("bearer-with-long-tail-aaaaaaaaaa"));
let calls = cb.invocations.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, ToolConsumer::WebSearch);
assert_eq!(calls[0].1.as_deref(), Some("bearer-with-"));
assert!(WebSearchClient::new(&config, None).is_err());
}
#[test]
fn render_results_follows_kimi_cli_schema() {
let results = vec![
SearchResult {
site_name: "Rust Blog".into(),
title: "Announcing Rust".into(),
url: "https://blog.rust-lang.org/a".into(),
snippet: "The release".into(),
content: String::new(),
date: "2026-01-01".into(),
},
SearchResult {
site_name: "Docs".into(),
title: "The Book".into(),
url: "https://doc.rust-lang.org/book".into(),
snippet: "Learn Rust".into(),
content: "Full crawled page text".into(),
date: String::new(),
},
];
let (content, citations) = render_results(&results);
assert_eq!(
calls[0].1.as_deref().map(str::len),
Some(crate::attribution::SENT_BEARER_PREFIX_LEN),
content,
"Title: Announcing Rust\nDate: 2026-01-01\nURL: https://blog.rust-lang.org/a\n\
Summary: The release\n\n---\n\nTitle: The Book\nDate: \n\
URL: https://doc.rust-lang.org/book\nSummary: Learn Rust\n\n\
Full crawled page text\n\n"
);
assert_eq!(
citations,
[
"https://blog.rust-lang.org/a",
"https://doc.rust-lang.org/book"
]
);
}
/// `record_401_attribution` is a no-op when no callback is wired
/// -- the BYOK / standalone case must not panic or allocate.
#[test]
fn record_401_attribution_is_noop_without_callback() {
let config = WebSearchConfig::Enabled {
api_key: "test-key".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "test-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
fn render_results_deduplicates_citations() {
let hit = SearchResult {
site_name: String::new(),
title: "T".into(),
url: "https://same.example".into(),
snippet: "S".into(),
content: String::new(),
date: String::new(),
};
let client = WebSearchClient::new(&config, None).expect("client should build");
client.record_401_attribution(Some("any-bearer"));
client.record_401_attribution(None);
let (_, citations) = render_results(&[hit.clone(), hit]);
assert_eq!(citations, ["https://same.example"]);
}
#[test]
fn test_extract_citations_empty_response() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "output" : [], "model" : "test-model" }
));
let citations = extract_citations(&response);
assert!(citations.is_empty());
}
#[test]
fn test_extract_citations_with_url_citations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Here is some info about Rust.", "annotations" : [{ "type" :
"url_citation", "url" : "https://www.rust-lang.org/", "title" :
"Rust Programming Language", "start_index" : 0, "end_index" : 10 }, {
"type" : "url_citation", "url" : "https://docs.rs/", "title" : "Docs.rs",
"start_index" : 11, "end_index" : 20 }] }] }] }
));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://www.rust-lang.org/");
assert_eq!(citations[1], "https://docs.rs/");
}
#[test]
fn test_extract_citations_deduplicates() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Info with duplicate citations.", "annotations" : [{ "type" :
"url_citation", "url" : "https://example.com/page1", "title" : "Page 1",
"start_index" : 0, "end_index" : 5 }, { "type" : "url_citation", "url" :
"https://example.com/page2", "title" : "Page 2", "start_index" : 6,
"end_index" : 10 }, { "type" : "url_citation", "url" :
"https://example.com/page1", "title" : "Page 1 Again", "start_index" :
11, "end_index" : 15 }] }] }] }
));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://example.com/page1");
assert_eq!(citations[1], "https://example.com/page2");
}
#[test]
fn test_extract_citations_multiple_messages() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "First message",
"annotations" : [{ "type" : "url_citation", "url" : "https://first.com/",
"title" : "First", "start_index" : 0, "end_index" : 5 }] }] }, { "type" :
"message", "id" : "msg_2", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Second message",
"annotations" : [{ "type" : "url_citation", "url" :
"https://second.com/", "title" : "Second", "start_index" : 0, "end_index"
: 6 }] }] }] }
));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://first.com/");
assert_eq!(citations[1], "https://second.com/");
}
#[test]
fn test_extract_citations_ignores_non_url_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Some text",
"annotations" : [{ "type" : "url_citation", "url" : "https://valid.com/",
"title" : "Valid", "start_index" : 0, "end_index" : 4 }] }] }] }
));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 1);
assert_eq!(citations[0], "https://valid.com/");
}
/// A provider that always returns `None`, simulating an API-key user
/// whose token has aged past the client-side TTL.
struct NoneProvider;
impl crate::types::ApiKeyProvider for NoneProvider {
fn current_api_key(&self) -> Option<String> {
None
}
}
/// When the dynamic provider returns `None`, the static `api_key`
/// from config must still be sent as the Authorization header.
/// This is a regression scenario: API-key users
/// past the 30-day client TTL saw 401 because no auth was sent.
#[tokio::test]
async fn static_api_key_is_fallback_when_provider_returns_none() {
use wiremock::matchers::{header, method, path};
async fn search_sends_kimi_wire_contract_and_parses_results() {
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer static-key-from-config"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "search result", "annotations" : []
}] }] }
)))
.and(path("/search"))
.and(header("authorization", "Bearer test-key"))
.and(header("x-msh-tool-call-id", "call-42"))
.and(body_json(serde_json::json!({
"text_query": "rust ownership",
"limit": 5,
"enable_page_crawling": false,
"timeout_seconds": 30,
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"search_results": [{
"site_name": "Docs",
"title": "Ownership",
"url": "https://doc.rust-lang.org/ownership",
"snippet": "What is ownership?",
"content": "",
"date": "2026-05-01",
"icon": "",
"mime": ""
}]
})))
.expect(1)
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
api_key: "static-key-from-config".to_string(),
base_url: server.uri(),
model: "test-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
};
let provider: SharedApiKeyProvider = std::sync::Arc::new(NoneProvider);
let client = WebSearchClient::new(&config, Some(provider)).expect("client should build");
let (content, _citations) = client
.search("test query", None)
let client =
WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None)
.unwrap();
let (content, citations) = client
.search("rust ownership", 5, false, "call-42")
.await
.expect("search must succeed with static key fallback");
assert_eq!(content, "search result");
.unwrap();
assert!(content.contains("Title: Ownership"));
assert!(content.contains("URL: https://doc.rust-lang.org/ownership"));
assert_eq!(citations, ["https://doc.rust-lang.org/ownership"]);
}
/// When the provider returns a fresh key, it overrides the static one.
#[tokio::test]
async fn provider_key_overrides_static_key() {
use wiremock::matchers::{header, method, path};
async fn search_maps_401_to_unauthorized() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
struct FreshProvider;
impl crate::types::ApiKeyProvider for FreshProvider {
fn current_api_key(&self) -> Option<String> {
Some("fresh-key-from-provider".to_string())
}
}
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer fresh-key-from-provider"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "fresh result", "annotations" : [] }]
}] }
)))
.and(path("/search"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
api_key: "stale-static-key".to_string(),
base_url: server.uri(),
model: "test-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
};
let provider: SharedApiKeyProvider = std::sync::Arc::new(FreshProvider);
let client = WebSearchClient::new(&config, Some(provider)).expect("client should build");
let (content, _citations) = client
.search("test query", None)
.await
.expect("search must succeed with provider key");
assert_eq!(content, "fresh result");
let client =
WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None)
.unwrap();
let err = client.search("q", 5, false, "c").await.unwrap_err();
assert!(err.to_string().contains("401"), "{err}");
}
#[test]
fn test_extract_citations_no_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Plain text with no annotations", "annotations" : [] }] }] }
));
let citations = extract_citations(&response);
assert!(citations.is_empty());
#[tokio::test]
async fn search_surfaces_server_errors() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let client =
WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None)
.unwrap();
let err = client.search("q", 5, false, "c").await.unwrap_err();
assert!(err.to_string().contains("503"), "{err}");
}
}
@@ -1,22 +1,25 @@
use indexmap::IndexMap;
/// Configuration for the web search tool.
/// Configuration for the `web_search` tool (PRD F5).
///
/// Use `Disabled` when no API key is available or web search should be turned off.
/// Use `Enabled { … }` to provide credentials and endpoint configuration.
/// The Kimi search service exists only on the Kimi Code subscription channel
/// (`POST {coding_base}/search`, kimi-cli `auth/platforms.py`:
/// `search_url=f"{_kimi_code_base_url()}/search"`), so the shell enables this
/// only for OAuth sessions — API-key-only sessions get `Disabled` and the
/// tool is absent.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum WebSearchConfig {
#[default]
Disabled,
Enabled {
/// Full POST endpoint, e.g. `https://api.kimi.com/coding/v1/search`.
search_url: String,
/// Initial bearer token; a live token from the api-key provider
/// (OAuth refresh) takes precedence per request.
api_key: String,
base_url: String,
model: String,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
extra_headers: IndexMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
alpha_test_key: Option<String>,
},
}
@@ -26,24 +29,19 @@ impl WebSearchConfig {
matches!(self, Self::Enabled { .. })
}
/// Return a copy safe for returning to clients.
///
/// The `api_key` is replaced with `"***REDACTED***"` and the optional
/// extra access key field is stripped.
/// Return a copy safe for returning to clients: the `api_key` is
/// replaced with `"***REDACTED***"`.
pub fn redacted(&self) -> Self {
match self {
Self::Disabled => Self::Disabled,
Self::Enabled {
base_url,
model,
search_url,
extra_headers,
..
} => Self::Enabled {
search_url: search_url.clone(),
api_key: "***REDACTED***".to_string(),
base_url: base_url.clone(),
model: model.clone(),
extra_headers: extra_headers.clone(),
alpha_test_key: None,
},
}
}
@@ -59,71 +57,38 @@ mod tests {
assert!(!config.is_enabled());
}
#[test]
fn test_config_enabled() {
let config = WebSearchConfig::Enabled {
api_key: "test-key".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "test-web-search-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
};
assert!(config.is_enabled());
}
#[test]
fn test_config_redacted() {
let mut headers = IndexMap::new();
headers.insert("X-Custom".to_string(), "value".to_string());
let config = WebSearchConfig::Enabled {
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
api_key: "secret-key-12345".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "test-web-search-model".to_string(),
extra_headers: headers,
alpha_test_key: Some("alpha-secret".to_string()),
};
let redacted = config.redacted();
match redacted {
match config.redacted() {
WebSearchConfig::Enabled {
search_url,
api_key,
base_url,
model,
extra_headers,
alpha_test_key,
} => {
assert_eq!(api_key, "***REDACTED***");
assert_eq!(base_url, "https://api.x.ai/v1");
assert_eq!(model, "test-web-search-model");
assert_eq!(search_url, "https://api.kimi.com/coding/v1/search");
assert_eq!(extra_headers.get("X-Custom").unwrap(), "value");
assert!(alpha_test_key.is_none());
}
_ => panic!("Expected Enabled variant"),
WebSearchConfig::Disabled => panic!("expected Enabled variant"),
}
}
#[test]
fn test_config_serde_roundtrip() {
let config = WebSearchConfig::Enabled {
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
api_key: "key".to_string(),
base_url: "https://api.x.ai/v1".to_string(),
model: "test-web-search-model".to_string(),
extra_headers: IndexMap::new(),
alpha_test_key: None,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: WebSearchConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.is_enabled());
}
#[test]
fn test_config_deserialize_from_set_options_payload() {
let json = r#"{
"status": "enabled",
"api_key": "xai-abc123",
"base_url": "https://api.x.ai/v1",
"model": "test-web-search-model"
}"#;
let config: WebSearchConfig = serde_json::from_str(json).unwrap();
assert!(config.is_enabled());
}
}
@@ -1034,7 +1034,10 @@ impl ToolRegistryBuilder {
if let crate::implementations::grok_build::web_fetch::WebFetchConfig::Enabled { params } =
&ctx.web_fetch_config
{
match crate::implementations::grok_build::web_fetch::WebFetchClient::new(params) {
match crate::implementations::grok_build::web_fetch::WebFetchClient::new(
params,
ctx.api_key_provider.clone(),
) {
Ok(client) => {
resources.insert(client);
}
@@ -24,3 +24,22 @@ pub(crate) async fn resolve_bearer(provider: Option<&SharedApiKeyProvider>) -> O
None => None,
}
}
/// Test fixtures shared by tool-client tests.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
struct FixedProvider(String);
impl ApiKeyProvider for FixedProvider {
fn current_api_key(&self) -> Option<String> {
Some(self.0.clone())
}
}
/// A provider that always yields `token` — stands in for the OAuth
/// refresh chain in client tests.
pub(crate) fn fixed_provider(token: &str) -> SharedApiKeyProvider {
Arc::new(FixedProvider(token.to_owned()))
}
}
@@ -187,7 +187,8 @@ mod tests {
assert_eq!(kill.unwrap().task_id, "t1");
let ws: Result<WebSearchInput, _> = ToolInput::WebSearch(WebSearchInput {
query: "q".into(),
allowed_domains: None,
limit: None,
include_content: None,
})
.try_into();
assert_eq!(ws.unwrap().query, "q");