返回 CodeWhale
provider_native_search.rs
根目录 / crates / tui / src / client / provider_native_search.rs
1 //! Narrow provider-native web-search client.
2 //!
3 //! This adapter reuses the active route's authenticated HTTP client without
4 //! exposing credentials to tool code. Route capability facts decide whether
5 //! the adapter is attached; this module only speaks the three documented
6 //! first-party wire contracts.
7
8 use anyhow::{Context, Result, bail};
9 use serde_json::{Value, json};
10
11 use crate::config::ApiProvider;
12
13 use super::{DeepSeekClient, api_url};
14
15 const MAX_NATIVE_ANSWER_CHARS: usize = 4_000;
16
17 #[derive(Clone)]
18 pub(crate) struct ProviderNativeSearchClient {
19 inner: DeepSeekClient,
20 }
21
22 #[derive(Clone)]
23 pub(crate) struct ProviderNativeSearchRequest {
24 pub(crate) query: String,
25 pub(crate) max_results: u8,
26 pub(crate) domains: Vec<String>,
27 }
28
29 #[derive(Clone, PartialEq, Eq)]
30 pub(crate) struct ProviderNativeCitation {
31 pub(crate) url: String,
32 pub(crate) title: String,
33 pub(crate) snippet: Option<String>,
34 pub(crate) published: Option<String>,
35 }
36
37 #[derive(Clone, PartialEq, Eq)]
38 pub(crate) struct ProviderNativeSearchResponse {
39 pub(crate) answer: Option<String>,
40 pub(crate) citations: Vec<ProviderNativeCitation>,
41 }
42
43 impl ProviderNativeSearchClient {
44 #[must_use]
45 pub(crate) fn new(inner: DeepSeekClient) -> Option<Self> {
46 matches!(
47 inner.api_provider,
48 ApiProvider::Openai | ApiProvider::Anthropic | ApiProvider::Xai
49 )
50 .then_some(Self { inner })
51 }
52
53 #[must_use]
54 pub(crate) fn provider(&self) -> ApiProvider {
55 self.inner.api_provider
56 }
57
58 #[must_use]
59 pub(crate) fn model(&self) -> &str {
60 &self.inner.default_model
61 }
62
63 #[must_use]
64 pub(crate) fn host(&self) -> Option<String> {
65 reqwest::Url::parse(&self.inner.base_url)
66 .ok()
67 .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
68 }
69
70 #[must_use]
71 pub(crate) fn cache_identity(&self) -> String {
72 format!(
73 "provider-native://{}/{}/{}",
74 self.inner.api_provider.as_str(),
75 self.host().as_deref().unwrap_or("unknown-host"),
76 self.inner.default_model
77 )
78 }
79
80 #[must_use]
81 pub(crate) const fn maximum_domain_count(&self) -> Option<usize> {
82 match self.inner.api_provider {
83 ApiProvider::Xai => Some(5),
84 ApiProvider::Openai => Some(100),
85 ApiProvider::Anthropic => None,
86 _ => Some(0),
87 }
88 }
89
90 pub(crate) async fn search(
91 &self,
92 request: &ProviderNativeSearchRequest,
93 ) -> Result<ProviderNativeSearchResponse> {
94 let body = match self.inner.api_provider {
95 ApiProvider::Openai => build_responses_search_body(
96 &self.inner.default_model,
97 request,
98 ResponsesSearchDialect::Openai,
99 ),
100 ApiProvider::Xai => build_responses_search_body(
101 &self.inner.default_model,
102 request,
103 ResponsesSearchDialect::Xai,
104 ),
105 ApiProvider::Anthropic => {
106 build_anthropic_search_body(&self.inner.default_model, request)
107 }
108 _ => bail!("active provider has no native web-search adapter"),
109 };
110 let url = match self.inner.api_provider {
111 ApiProvider::Openai | ApiProvider::Xai => api_url(&self.inner.base_url, "responses"),
112 ApiProvider::Anthropic => anthropic_messages_url(&self.inner.base_url),
113 _ => unreachable!("provider checked above"),
114 };
115 let body_bytes = serde_json::to_vec(&body)
116 .context("failed to serialize provider-native web-search request")?;
117 let response = self
118 .inner
119 .send_with_retry(|| {
120 self.inner
121 .http_client
122 .post(&url)
123 .header("Accept", "application/json")
124 .body(body_bytes.clone())
125 })
126 .await
127 .context("provider-native web search request failed")?;
128 let payload = response
129 .json::<Value>()
130 .await
131 .context("provider-native web search returned invalid JSON")?;
132 let mut parsed = match self.inner.api_provider {
133 ApiProvider::Openai | ApiProvider::Xai => parse_responses_search(&payload),
134 ApiProvider::Anthropic => parse_anthropic_search(&payload),
135 _ => unreachable!("provider checked above"),
136 };
137 parsed.citations.truncate(usize::from(request.max_results));
138 Ok(parsed)
139 }
140 }
141
142 #[derive(Clone, Copy)]
143 enum ResponsesSearchDialect {
144 Openai,
145 Xai,
146 }
147
148 fn search_prompt(request: &ProviderNativeSearchRequest) -> String {
149 format!(
150 "Search the web for the following query and answer only from web sources. \
151 Use concise prose with citations and prefer at most {} distinct sources.\n\n{}",
152 request.max_results, request.query
153 )
154 }
155
156 fn build_responses_search_body(
157 model: &str,
158 request: &ProviderNativeSearchRequest,
159 dialect: ResponsesSearchDialect,
160 ) -> Value {
161 let mut tool = json!({ "type": "web_search" });
162 if !request.domains.is_empty() {
163 tool["filters"] = json!({ "allowed_domains": request.domains });
164 }
165 let mut body = json!({
166 "model": model,
167 "input": search_prompt(request),
168 "tools": [tool],
169 "tool_choice": "required",
170 "store": false,
171 });
172 if matches!(dialect, ResponsesSearchDialect::Openai) {
173 body["include"] = json!(["web_search_call.action.sources"]);
174 } else {
175 // xAI documents the same Responses tool shape but not OpenAI's
176 // source-inclusion extension. Citations are recovered from xAI's
177 // response annotations / citations field instead.
178 body.as_object_mut()
179 .expect("search body is an object")
180 .remove("store");
181 }
182 body
183 }
184
185 fn build_anthropic_search_body(model: &str, request: &ProviderNativeSearchRequest) -> Value {
186 let mut tool = json!({
187 "type": "web_search_20250305",
188 "name": "web_search",
189 "max_uses": 1,
190 });
191 if !request.domains.is_empty() {
192 tool["allowed_domains"] = json!(request.domains);
193 }
194 json!({
195 "model": model,
196 "max_tokens": 2048,
197 "messages": [{ "role": "user", "content": search_prompt(request) }],
198 "tools": [tool],
199 })
200 }
201
202 fn anthropic_messages_url(base_url: &str) -> String {
203 let base = base_url.trim_end_matches('/');
204 if base.ends_with("/v1") {
205 format!("{base}/messages")
206 } else {
207 format!("{base}/v1/messages")
208 }
209 }
210
211 fn parse_responses_search(payload: &Value) -> ProviderNativeSearchResponse {
212 let mut answer_parts = Vec::new();
213 let mut citations = Vec::new();
214 if let Some(output) = payload.get("output").and_then(Value::as_array) {
215 for item in output {
216 if let Some(sources) = item.pointer("/action/sources").and_then(Value::as_array) {
217 for source in sources {
218 push_citation(&mut citations, citation_from_value(source, None, None));
219 }
220 }
221 if let Some(content) = item.get("content").and_then(Value::as_array) {
222 for block in content {
223 if let Some(text) = block.get("text").and_then(Value::as_str)
224 && !text.trim().is_empty()
225 {
226 answer_parts.push(text.trim().to_string());
227 }
228 if let Some(annotations) = block.get("annotations").and_then(Value::as_array) {
229 for annotation in annotations {
230 push_citation(
231 &mut citations,
232 citation_from_value(annotation, None, None),
233 );
234 }
235 }
236 }
237 }
238 }
239 }
240 if answer_parts.is_empty()
241 && let Some(output_text) = payload.get("output_text").and_then(Value::as_str)
242 && !output_text.trim().is_empty()
243 {
244 answer_parts.push(output_text.trim().to_string());
245 }
246 if let Some(top_level) = payload.get("citations").and_then(Value::as_array) {
247 for citation in top_level {
248 let parsed = citation
249 .as_str()
250 .and_then(|url| citation_from_url(url, None, None, None))
251 .or_else(|| citation_from_value(citation, None, None));
252 push_citation(&mut citations, parsed);
253 }
254 }
255 ProviderNativeSearchResponse {
256 answer: bounded_answer(answer_parts),
257 citations,
258 }
259 }
260
261 fn parse_anthropic_search(payload: &Value) -> ProviderNativeSearchResponse {
262 let mut answer_parts = Vec::new();
263 let mut citations = Vec::new();
264 if let Some(content) = payload.get("content").and_then(Value::as_array) {
265 for block in content {
266 match block.get("type").and_then(Value::as_str) {
267 Some("web_search_tool_result") => {
268 if let Some(results) = block.get("content").and_then(Value::as_array) {
269 for result in results {
270 let published = result
271 .get("page_age")
272 .and_then(Value::as_str)
273 .map(str::to_string);
274 push_citation(
275 &mut citations,
276 citation_from_value(result, None, published),
277 );
278 }
279 }
280 }
281 Some("text") => {
282 if let Some(text) = block.get("text").and_then(Value::as_str)
283 && !text.trim().is_empty()
284 {
285 answer_parts.push(text.trim().to_string());
286 }
287 if let Some(block_citations) = block.get("citations").and_then(Value::as_array)
288 {
289 for citation in block_citations {
290 let snippet = citation
291 .get("cited_text")
292 .and_then(Value::as_str)
293 .map(str::to_string);
294 push_citation(
295 &mut citations,
296 citation_from_value(citation, snippet, None),
297 );
298 }
299 }
300 }
301 _ => {}
302 }
303 }
304 }
305 ProviderNativeSearchResponse {
306 answer: bounded_answer(answer_parts),
307 citations,
308 }
309 }
310
311 fn citation_from_value(
312 value: &Value,
313 snippet: Option<String>,
314 published: Option<String>,
315 ) -> Option<ProviderNativeCitation> {
316 let url = value.get("url").and_then(Value::as_str)?.trim();
317 let title = value
318 .get("title")
319 .and_then(Value::as_str)
320 .map(str::trim)
321 .filter(|title| !title.is_empty())
322 .map(str::to_string);
323 citation_from_url(url, title, snippet, published)
324 }
325
326 fn citation_from_url(
327 url: &str,
328 title: Option<String>,
329 snippet: Option<String>,
330 published: Option<String>,
331 ) -> Option<ProviderNativeCitation> {
332 let parsed = reqwest::Url::parse(url).ok()?;
333 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
334 return None;
335 }
336 Some(ProviderNativeCitation {
337 url: url.to_string(),
338 title: title.unwrap_or_else(|| fallback_title(url)),
339 snippet,
340 published,
341 })
342 }
343
344 fn push_citation(
345 citations: &mut Vec<ProviderNativeCitation>,
346 candidate: Option<ProviderNativeCitation>,
347 ) {
348 let Some(candidate) = candidate else {
349 return;
350 };
351 if let Some(existing) = citations
352 .iter_mut()
353 .find(|existing| existing.url == candidate.url)
354 {
355 if existing.title == fallback_title(&existing.url)
356 && candidate.title != fallback_title(&candidate.url)
357 {
358 existing.title = candidate.title;
359 }
360 if existing.snippet.is_none() {
361 existing.snippet = candidate.snippet;
362 }
363 if existing.published.is_none() {
364 existing.published = candidate.published;
365 }
366 return;
367 }
368 citations.push(candidate);
369 }
370
371 fn fallback_title(url: &str) -> String {
372 reqwest::Url::parse(url)
373 .ok()
374 .and_then(|parsed| parsed.host_str().map(str::to_string))
375 .unwrap_or_else(|| "Web source".to_string())
376 }
377
378 fn bounded_answer(parts: Vec<String>) -> Option<String> {
379 let joined = parts.join("\n\n");
380 let trimmed = joined.trim();
381 if trimmed.is_empty() {
382 return None;
383 }
384 if trimmed.chars().count() <= MAX_NATIVE_ANSWER_CHARS {
385 return Some(trimmed.to_string());
386 }
387 let mut bounded = trimmed
388 .chars()
389 .take(MAX_NATIVE_ANSWER_CHARS.saturating_sub(1))
390 .collect::<String>();
391 bounded.push('…');
392 Some(bounded)
393 }
394
395 #[cfg(test)]
396 mod tests {
397 use super::*;
398 use crate::config::{Config, ProviderConfig, ProvidersConfig};
399 use wiremock::matchers::{body_partial_json, header, method, path};
400 use wiremock::{Mock, MockServer, ResponseTemplate};
401
402 fn request() -> ProviderNativeSearchRequest {
403 ProviderNativeSearchRequest {
404 query: "current release".to_string(),
405 max_results: 3,
406 domains: vec!["example.com".to_string()],
407 }
408 }
409
410 #[test]
411 fn responses_payload_requires_search_and_keeps_domains_provider_side() {
412 let body =
413 build_responses_search_body("gpt-5.6", &request(), ResponsesSearchDialect::Openai);
414 assert_eq!(body["tools"][0]["type"], "web_search");
415 assert_eq!(
416 body["tools"][0]["filters"]["allowed_domains"][0],
417 "example.com"
418 );
419 assert_eq!(body["tool_choice"], "required");
420 assert_eq!(body["include"][0], "web_search_call.action.sources");
421 }
422
423 #[test]
424 fn anthropic_payload_uses_basic_direct_search_contract() {
425 let body = build_anthropic_search_body("claude-opus-4-8", &request());
426 assert_eq!(body["tools"][0]["type"], "web_search_20250305");
427 assert_eq!(body["tools"][0]["max_uses"], 1);
428 assert_eq!(body["tools"][0]["allowed_domains"][0], "example.com");
429 }
430
431 #[test]
432 fn responses_parser_separates_answer_and_deduplicated_citations() {
433 let payload = json!({
434 "output": [
435 {
436 "type": "web_search_call",
437 "action": { "sources": [
438 { "url": "https://example.com/a", "title": "Source A" }
439 ] }
440 },
441 {
442 "type": "message",
443 "content": [{
444 "type": "output_text",
445 "text": "Grounded answer.",
446 "annotations": [
447 { "type": "url_citation", "url": "https://example.com/a", "title": "Source A" },
448 { "type": "url_citation", "url": "https://example.org/b", "title": "Source B" }
449 ]
450 }]
451 }
452 ]
453 });
454 let parsed = parse_responses_search(&payload);
455 assert_eq!(parsed.answer.as_deref(), Some("Grounded answer."));
456 assert_eq!(parsed.citations.len(), 2);
457 assert_eq!(parsed.citations[0].title, "Source A");
458 assert_eq!(parsed.citations[1].url, "https://example.org/b");
459 }
460
461 #[test]
462 fn anthropic_parser_keeps_result_metadata_and_cited_text_separate() {
463 let payload = json!({
464 "content": [
465 {
466 "type": "web_search_tool_result",
467 "content": [{
468 "type": "web_search_result",
469 "url": "https://example.com/a",
470 "title": "Source A",
471 "page_age": "July 18, 2026"
472 }]
473 },
474 {
475 "type": "text",
476 "text": "Grounded answer.",
477 "citations": [{
478 "type": "web_search_result_location",
479 "url": "https://example.com/a",
480 "title": "Source A",
481 "cited_text": "Supporting passage"
482 }]
483 }
484 ]
485 });
486 let parsed = parse_anthropic_search(&payload);
487 assert_eq!(parsed.answer.as_deref(), Some("Grounded answer."));
488 assert_eq!(parsed.citations.len(), 1);
489 assert_eq!(
490 parsed.citations[0].published.as_deref(),
491 Some("July 18, 2026")
492 );
493 assert_eq!(
494 parsed.citations[0].snippet.as_deref(),
495 Some("Supporting passage")
496 );
497 }
498
499 #[test]
500 fn non_http_citations_are_rejected() {
501 let payload = json!({ "citations": ["javascript:alert(1)"] });
502 assert!(parse_responses_search(&payload).citations.is_empty());
503 }
504
505 #[tokio::test]
506 async fn xai_adapter_reuses_active_authenticated_transport() {
507 let server = MockServer::start().await;
508 Mock::given(method("POST"))
509 .and(path("/v1/responses"))
510 .and(header("authorization", "Bearer xai-test-key"))
511 .and(body_partial_json(json!({
512 "model": "grok-4.5",
513 "tools": [{
514 "type": "web_search",
515 "filters": { "allowed_domains": ["example.com"] }
516 }],
517 "tool_choice": "required"
518 })))
519 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
520 "output_text": "Grounded answer.",
521 "citations": ["https://example.com/source"]
522 })))
523 .expect(1)
524 .mount(&server)
525 .await;
526 let config = Config {
527 provider: Some("xai".to_string()),
528 providers: Some(ProvidersConfig {
529 xai: ProviderConfig {
530 api_key: Some("xai-test-key".to_string()),
531 base_url: Some(format!("{}/v1", server.uri())),
532 model: Some("grok-4.5".to_string()),
533 ..ProviderConfig::default()
534 },
535 ..ProvidersConfig::default()
536 }),
537 ..Config::default()
538 };
539 let inner = DeepSeekClient::new(&config).expect("test xAI client");
540 let client = ProviderNativeSearchClient::new(inner).expect("xAI native adapter");
541 let cache_identity = client.cache_identity();
542 assert!(cache_identity.contains("provider-native://xai/"));
543 assert!(cache_identity.ends_with("/grok-4.5"));
544 assert!(!cache_identity.contains("xai-test-key"));
545
546 let response = client.search(&request()).await.expect("native search");
547
548 assert_eq!(response.answer.as_deref(), Some("Grounded answer."));
549 assert_eq!(response.citations.len(), 1);
550 assert_eq!(response.citations[0].url, "https://example.com/source");
551 }
552 }
553
553 lines RUST