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:
@@ -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(¶ms, Some(provider)).unwrap();
|
||||
let output = client
|
||||
.fetch_via_service(
|
||||
¶ms.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(¶ms, 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(¶ms, 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(¶ms);
|
||||
let client = WebFetchClient::new(¶ms, 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");
|
||||
|
||||
Reference in New Issue
Block a user