返回 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 reqwest::header::{HeaderName, HeaderValue};
10 use serde_json::{Value, json};
11
12 use crate::config::ApiProvider;
13
14 use super::{CodewhaleClient, api_url, responses_api_url};
15
16 mod zai;
17
18 mod kimi;
19
20 const MAX_NATIVE_ANSWER_CHARS: usize = 4_000;
21
22 #[derive(Clone)]
23 pub(crate) struct ProviderNativeSearchClient {
24 pub(super) inner: CodewhaleClient,
25 }
26
27 #[derive(Clone)]
28 pub(crate) struct ProviderNativeSearchRequest {
29 pub(crate) query: String,
30 pub(crate) max_results: u8,
31 pub(crate) domains: Vec<String>,
32 }
33
34 #[derive(Clone, PartialEq, Eq)]
35 pub(crate) struct ProviderNativeCitation {
36 pub(crate) url: String,
37 pub(crate) title: String,
38 pub(crate) snippet: Option<String>,
39 pub(crate) published: Option<String>,
40 }
41
42 #[derive(Clone, PartialEq, Eq)]
43 pub(crate) struct ProviderNativeSearchResponse {
44 pub(crate) answer: Option<String>,
45 pub(crate) citations: Vec<ProviderNativeCitation>,
46 }
47
48 impl ProviderNativeSearchClient {
49 #[must_use]
50 pub(crate) fn new(inner: CodewhaleClient) -> Option<Self> {
51 matches!(
52 inner.api_provider,
53 ApiProvider::Openai
54 | ApiProvider::Anthropic
55 | ApiProvider::Xai
56 | ApiProvider::XiaomiMimo
57 | ApiProvider::Zai
58 | ApiProvider::ModelstudioTokenPlan
59 | ApiProvider::Deepseek
60 | ApiProvider::DeepseekCN
61 | ApiProvider::Moonshot
62 )
63 .then_some(Self { inner })
64 }
65
66 #[must_use]
67 pub(crate) fn provider(&self) -> ApiProvider {
68 self.inner.api_provider
69 }
70
71 #[must_use]
72 pub(crate) fn model(&self) -> &str {
73 &self.inner.default_model
74 }
75
76 #[must_use]
77 pub(crate) fn base_url(&self) -> &str {
78 &self.inner.base_url
79 }
80
81 #[must_use]
82 pub(crate) fn host(&self) -> Option<String> {
83 reqwest::Url::parse(&self.inner.base_url)
84 .ok()
85 .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
86 }
87
88 #[must_use]
89 pub(crate) fn cache_identity(&self) -> String {
90 format!(
91 "provider-native://{}/{}/{}",
92 self.inner.api_provider.as_str(),
93 self.host().as_deref().unwrap_or("unknown-host"),
94 self.inner.default_model
95 )
96 }
97
98 #[must_use]
99 pub(crate) const fn maximum_domain_count(&self) -> Option<usize> {
100 match self.inner.api_provider {
101 ApiProvider::Xai => Some(5),
102 ApiProvider::Openai => Some(100),
103 ApiProvider::Anthropic => None,
104 _ => Some(0),
105 }
106 }
107
108 pub(crate) async fn search(
109 &self,
110 request: &ProviderNativeSearchRequest,
111 ) -> Result<ProviderNativeSearchResponse> {
112 // This adapter performs model-backed inference directly instead of
113 // calling `CodewhaleClient::create_message*`. It must therefore join
114 // the same attached-run ownership boundary explicitly. The guard is
115 // retained through response decode so a relay writer cannot start
116 // while this result is still able to feed the interactive turn.
117 let _inference = self.inner.acquire_remote_control_inference_permit().await;
118 if self.inner.api_provider == ApiProvider::Moonshot {
119 // Kimi/Moonshot runs a bounded multi-round agentic search with its
120 // own request/reply loop, so it cannot share the single-shot body
121 // dispatch below. It still runs under the inference permit above.
122 let mut parsed = kimi::search(self, request).await?;
123 parsed.citations.truncate(usize::from(request.max_results));
124 return Ok(parsed);
125 }
126 let body = match self.inner.api_provider {
127 ApiProvider::Openai => build_responses_search_body(
128 &self.inner.default_model,
129 request,
130 ResponsesSearchDialect::Openai,
131 ),
132 ApiProvider::Xai => build_responses_search_body(
133 &self.inner.default_model,
134 request,
135 ResponsesSearchDialect::Xai,
136 ),
137 ApiProvider::ModelstudioTokenPlan => build_responses_search_body(
138 &self.inner.default_model,
139 request,
140 ResponsesSearchDialect::ModelStudio,
141 ),
142 ApiProvider::Deepseek | ApiProvider::DeepseekCN => build_responses_search_body(
143 &self.inner.default_model,
144 request,
145 ResponsesSearchDialect::Deepseek,
146 ),
147 ApiProvider::Anthropic => {
148 let route_cap = self
149 .inner
150 .effective_max_output_tokens(&self.inner.default_model);
151 build_anthropic_search_body(
152 &self.inner.default_model,
153 request,
154 2_048_u32.min(route_cap),
155 )
156 }
157 ApiProvider::XiaomiMimo => build_mimo_search_body(&self.inner.default_model, request),
158 ApiProvider::Zai => zai::build_body(request, &self.inner.base_url)?,
159 _ => bail!("active provider has no native web-search adapter"),
160 };
161 let url = match self.inner.api_provider {
162 ApiProvider::Openai | ApiProvider::Xai | ApiProvider::ModelstudioTokenPlan => {
163 api_url(&self.inner.base_url, "responses")
164 }
165 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
166 responses_api_url(&self.inner.base_url, self.inner.api_provider)
167 }
168 ApiProvider::Anthropic => anthropic_messages_url(&self.inner.base_url),
169 ApiProvider::XiaomiMimo => api_url(&self.inner.base_url, "chat/completions"),
170 ApiProvider::Zai => api_url(&self.inner.base_url, "web_search"),
171 _ => unreachable!("provider checked above"),
172 };
173 let body_bytes = serde_json::to_vec(&body)
174 .context("failed to serialize provider-native web-search request")?;
175 let response = self
176 .inner
177 .send_with_retry(|| {
178 self.inner
179 .http_client
180 .post(&url)
181 .header("Accept", "application/json")
182 .body(body_bytes.clone())
183 })
184 .await
185 .context("provider-native web search request failed")?;
186 let payload = response
187 .json::<Value>()
188 .await
189 .context("provider-native web search returned invalid JSON")?;
190 let mut parsed = match self.inner.api_provider {
191 ApiProvider::Openai
192 | ApiProvider::Xai
193 | ApiProvider::ModelstudioTokenPlan
194 | ApiProvider::Deepseek
195 | ApiProvider::DeepseekCN => parse_responses_search(&payload),
196 ApiProvider::Anthropic => parse_anthropic_search(&payload),
197 ApiProvider::XiaomiMimo => parse_mimo_search(&payload),
198 ApiProvider::Zai => zai::parse(&payload),
199 _ => unreachable!("provider checked above"),
200 };
201 parsed.citations.truncate(usize::from(request.max_results));
202 Ok(parsed)
203 }
204
205 pub(super) async fn post_json(
206 &self,
207 url: &str,
208 body: &Value,
209 headers: &[(HeaderName, HeaderValue)],
210 ) -> Result<Value> {
211 let body_bytes = serde_json::to_vec(&body)
212 .context("failed to serialize provider-native web-search request")?;
213 let headers = headers.to_vec();
214 let response = self
215 .inner
216 .send_with_retry(|| {
217 let mut request = self
218 .inner
219 .http_client
220 .post(url)
221 .header("Accept", "application/json")
222 .body(body_bytes.clone());
223 for (name, value) in &headers {
224 request = request.header(name, value);
225 }
226 request
227 })
228 .await
229 .context("provider-native web search request failed")?;
230 response
231 .json::<Value>()
232 .await
233 .context("provider-native web search returned invalid JSON")
234 }
235
236 pub(super) async fn get_json(&self, url: &str) -> Result<Value> {
237 let response = self
238 .inner
239 .send_with_retry(|| {
240 self.inner
241 .http_client
242 .get(url)
243 .header("Accept", "application/json")
244 })
245 .await
246 .context("provider-native web search request failed")?;
247 response
248 .json::<Value>()
249 .await
250 .context("provider-native web search returned invalid JSON")
251 }
252 }
253
254 #[derive(Clone, Copy)]
255 enum ResponsesSearchDialect {
256 Openai,
257 Xai,
258 ModelStudio,
259 Deepseek,
260 }
261
262 fn search_prompt(request: &ProviderNativeSearchRequest) -> String {
263 format!(
264 "Search the web for the following query and answer only from web sources. \
265 Use concise prose with citations and prefer at most {} distinct sources.\n\n{}",
266 request.max_results, request.query
267 )
268 }
269
270 fn build_responses_search_body(
271 model: &str,
272 request: &ProviderNativeSearchRequest,
273 dialect: ResponsesSearchDialect,
274 ) -> Value {
275 let mut tool = json!({ "type": "web_search" });
276 if !request.domains.is_empty()
277 && matches!(
278 dialect,
279 ResponsesSearchDialect::Openai | ResponsesSearchDialect::Xai
280 )
281 {
282 tool["filters"] = json!({ "allowed_domains": request.domains });
283 }
284 let mut body = json!({
285 "model": model,
286 "input": search_prompt(request),
287 "tools": [tool],
288 });
289 match dialect {
290 ResponsesSearchDialect::Openai => {
291 body["tool_choice"] = json!("required");
292 body["store"] = json!(false);
293 body["include"] = json!(["web_search_call.action.sources"]);
294 }
295 ResponsesSearchDialect::Xai => {
296 body["tool_choice"] = json!("required");
297 }
298 ResponsesSearchDialect::ModelStudio => {
299 body["tool_choice"] = json!("required");
300 }
301 ResponsesSearchDialect::Deepseek => {
302 body["tool_choice"] = json!({ "type": "web_search" });
303 }
304 }
305 body
306 }
307
308 fn build_anthropic_search_body(
309 model: &str,
310 request: &ProviderNativeSearchRequest,
311 max_tokens: u32,
312 ) -> Value {
313 let mut tool = json!({
314 "type": "web_search_20250305",
315 "name": "web_search",
316 "max_uses": 1,
317 });
318 if !request.domains.is_empty() {
319 tool["allowed_domains"] = json!(request.domains);
320 }
321 json!({
322 "model": model,
323 "max_tokens": max_tokens,
324 "messages": [{ "role": "user", "content": search_prompt(request) }],
325 "tools": [tool],
326 })
327 }
328
329 fn build_mimo_search_body(model: &str, request: &ProviderNativeSearchRequest) -> Value {
330 json!({
331 "model": model,
332 "messages": [{ "role": "user", "content": search_prompt(request) }],
333 "tools": [{
334 "type": "web_search",
335 "max_keyword": 1,
336 "force_search": true,
337 "limit": request.max_results,
338 }],
339 "tool_choice": "auto",
340 "max_completion_tokens": 2_048,
341 "stream": false,
342 "thinking": { "type": "disabled" },
343 })
344 }
345
346 fn anthropic_messages_url(base_url: &str) -> String {
347 let base = base_url.trim_end_matches('/');
348 if base.ends_with("/v1") {
349 format!("{base}/messages")
350 } else {
351 format!("{base}/v1/messages")
352 }
353 }
354
355 fn parse_responses_search(payload: &Value) -> ProviderNativeSearchResponse {
356 let mut answer_parts = Vec::new();
357 let mut citations = Vec::new();
358 if let Some(output) = payload.get("output").and_then(Value::as_array) {
359 for item in output {
360 let item_type = item.get("type").and_then(Value::as_str);
361 if item_type == Some("web_search_call")
362 && let Some(action) = item.get("action")
363 {
364 if let Some(sources) = action.get("sources").and_then(Value::as_array) {
365 for source in sources {
366 push_citation(&mut citations, citation_from_value(source, None, None));
367 }
368 }
369 push_citation(&mut citations, citation_from_value(action, None, None));
370 }
371
372 if item_type == Some("message")
373 && let Some(content) = item.get("content").and_then(Value::as_array)
374 {
375 for block in content {
376 if matches!(
377 block.get("type").and_then(Value::as_str),
378 Some("output_text" | "text")
379 ) && let Some(text) = block.get("text").and_then(Value::as_str)
380 && !text.trim().is_empty()
381 {
382 answer_parts.push(text.trim().to_string());
383 }
384 if let Some(annotations) = block.get("annotations").and_then(Value::as_array) {
385 for annotation in annotations {
386 push_citation(
387 &mut citations,
388 citation_from_value(annotation, None, None),
389 );
390 }
391 }
392 }
393 }
394 }
395 }
396 if answer_parts.is_empty()
397 && let Some(output_text) = payload.get("output_text").and_then(Value::as_str)
398 && !output_text.trim().is_empty()
399 {
400 answer_parts.push(output_text.trim().to_string());
401 }
402 for answer in &answer_parts {
403 for citation in citations_from_text(answer) {
404 push_citation(&mut citations, Some(citation));
405 }
406 }
407 if let Some(top_level) = payload.get("citations").and_then(Value::as_array) {
408 for citation in top_level {
409 let parsed = citation
410 .as_str()
411 .and_then(|url| citation_from_url(url, None, None, None))
412 .or_else(|| citation_from_value(citation, None, None));
413 push_citation(&mut citations, parsed);
414 }
415 }
416 ProviderNativeSearchResponse {
417 answer: bounded_answer(answer_parts),
418 citations,
419 }
420 }
421
422 fn parse_anthropic_search(payload: &Value) -> ProviderNativeSearchResponse {
423 let mut answer_parts = Vec::new();
424 let mut citations = Vec::new();
425 if let Some(content) = payload.get("content").and_then(Value::as_array) {
426 for block in content {
427 match block.get("type").and_then(Value::as_str) {
428 Some("web_search_tool_result") => {
429 if let Some(results) = block.get("content").and_then(Value::as_array) {
430 for result in results {
431 let published = result
432 .get("page_age")
433 .and_then(Value::as_str)
434 .map(str::to_string);
435 push_citation(
436 &mut citations,
437 citation_from_value(result, None, published),
438 );
439 }
440 }
441 }
442 Some("text") => {
443 if let Some(text) = block.get("text").and_then(Value::as_str)
444 && !text.trim().is_empty()
445 {
446 answer_parts.push(text.trim().to_string());
447 }
448 if let Some(block_citations) = block.get("citations").and_then(Value::as_array)
449 {
450 for citation in block_citations {
451 let snippet = citation
452 .get("cited_text")
453 .and_then(Value::as_str)
454 .map(str::to_string);
455 push_citation(
456 &mut citations,
457 citation_from_value(citation, snippet, None),
458 );
459 }
460 }
461 }
462 _ => {}
463 }
464 }
465 }
466 ProviderNativeSearchResponse {
467 answer: bounded_answer(answer_parts),
468 citations,
469 }
470 }
471
472 fn parse_mimo_search(payload: &Value) -> ProviderNativeSearchResponse {
473 let message = payload.pointer("/choices/0/message");
474 let answer = message
475 .and_then(|value| value.get("content"))
476 .and_then(Value::as_str)
477 .map(str::trim)
478 .filter(|text| !text.is_empty())
479 .map(str::to_string);
480 let mut citations = Vec::new();
481 if let Some(annotations) = message
482 .and_then(|value| value.get("annotations"))
483 .and_then(Value::as_array)
484 .or_else(|| payload.get("annotations").and_then(Value::as_array))
485 {
486 for annotation in annotations {
487 let Some(url) = annotation.get("url").and_then(Value::as_str) else {
488 continue;
489 };
490 let title = annotation
491 .get("title")
492 .and_then(Value::as_str)
493 .map(str::to_string);
494 let snippet = annotation
495 .get("summary")
496 .and_then(Value::as_str)
497 .map(str::to_string);
498 let published = annotation
499 .get("publish_time")
500 .and_then(Value::as_str)
501 .map(str::to_string);
502 push_citation(
503 &mut citations,
504 citation_from_url(url, title, snippet, published),
505 );
506 }
507 }
508 ProviderNativeSearchResponse {
509 answer: bounded_answer(answer.into_iter().collect()),
510 citations,
511 }
512 }
513
514 fn citation_from_value(
515 value: &Value,
516 snippet: Option<String>,
517 published: Option<String>,
518 ) -> Option<ProviderNativeCitation> {
519 let url = value.get("url").and_then(Value::as_str)?.trim();
520 let title = value
521 .get("title")
522 .and_then(Value::as_str)
523 .map(str::trim)
524 .filter(|title| !title.is_empty())
525 .map(str::to_string);
526 citation_from_url(url, title, snippet, published)
527 }
528
529 fn citation_from_url(
530 url: &str,
531 title: Option<String>,
532 snippet: Option<String>,
533 published: Option<String>,
534 ) -> Option<ProviderNativeCitation> {
535 let parsed = reqwest::Url::parse(url).ok()?;
536 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
537 return None;
538 }
539 Some(ProviderNativeCitation {
540 url: url.to_string(),
541 title: title.unwrap_or_else(|| fallback_title(url)),
542 snippet,
543 published,
544 })
545 }
546
547 fn push_citation(
548 citations: &mut Vec<ProviderNativeCitation>,
549 candidate: Option<ProviderNativeCitation>,
550 ) {
551 let Some(candidate) = candidate else {
552 return;
553 };
554 if let Some(existing) = citations
555 .iter_mut()
556 .find(|existing| existing.url == candidate.url)
557 {
558 if existing.title == fallback_title(&existing.url)
559 && candidate.title != fallback_title(&candidate.url)
560 {
561 existing.title = candidate.title;
562 }
563 if existing.snippet.is_none() {
564 existing.snippet = candidate.snippet;
565 }
566 if existing.published.is_none() {
567 existing.published = candidate.published;
568 }
569 return;
570 }
571 citations.push(candidate);
572 }
573
574 fn citations_from_text(text: &str) -> Vec<ProviderNativeCitation> {
575 let mut citations = Vec::new();
576 let mut offset = 0;
577 while offset < text.len() {
578 let remaining = &text[offset..];
579 let relative_start = match (remaining.find("https://"), remaining.find("http://")) {
580 (Some(https), Some(http)) => Some(https.min(http)),
581 (Some(https), None) => Some(https),
582 (None, Some(http)) => Some(http),
583 (None, None) => None,
584 };
585 let Some(relative_start) = relative_start else {
586 break;
587 };
588 let start = offset + relative_start;
589 let tail = &text[start..];
590 // Balanced parentheses belong to the URL (Wikipedia titles such as
591 // `Foo_(bar)` keep their closing paren); an unmatched closer ends it.
592 let mut open_parens = 0_usize;
593 let end = tail
594 .char_indices()
595 .find_map(|(index, ch)| match ch {
596 '(' => {
597 open_parens += 1;
598 None
599 }
600 ')' if open_parens > 0 => {
601 open_parens -= 1;
602 None
603 }
604 _ => (index > 0
605 && (ch.is_whitespace()
606 || matches!(ch, ')' | ']' | '}' | '>' | '"' | '\'' | '`')))
607 .then_some(index),
608 })
609 .unwrap_or(tail.len());
610 let url = tail[..end].trim_end_matches(['.', ',', ';', ':', '!', '?']);
611 push_citation(&mut citations, citation_from_url(url, None, None, None));
612 offset = start + end.max(1);
613 }
614 citations
615 }
616
617 fn fallback_title(url: &str) -> String {
618 reqwest::Url::parse(url)
619 .ok()
620 .and_then(|parsed| parsed.host_str().map(str::to_string))
621 .unwrap_or_else(|| "Web source".to_string())
622 }
623
624 fn bounded_answer(parts: Vec<String>) -> Option<String> {
625 let joined = parts.join("\n\n");
626 let trimmed = joined.trim();
627 if trimmed.is_empty() {
628 return None;
629 }
630 if trimmed.chars().count() <= MAX_NATIVE_ANSWER_CHARS {
631 return Some(trimmed.to_string());
632 }
633 let mut bounded = trimmed
634 .chars()
635 .take(MAX_NATIVE_ANSWER_CHARS.saturating_sub(1))
636 .collect::<String>();
637 bounded.push('…');
638 Some(bounded)
639 }
640
641 #[cfg(test)]
642 mod tests {
643 use super::*;
644 use crate::config::{Config, ProviderConfig, ProvidersConfig};
645 use wiremock::matchers::{body_partial_json, header, method, path};
646 use wiremock::{Mock, MockServer, ResponseTemplate};
647
648 fn request() -> ProviderNativeSearchRequest {
649 ProviderNativeSearchRequest {
650 query: "current release".to_string(),
651 max_results: 3,
652 domains: vec!["example.com".to_string()],
653 }
654 }
655
656 fn xai_client_with_boundary(
657 server: &MockServer,
658 isolated: bool,
659 unrelated: bool,
660 ) -> ProviderNativeSearchClient {
661 let config = Config {
662 provider: Some("xai".to_string()),
663 providers: Some(ProvidersConfig {
664 xai: ProviderConfig {
665 api_key: Some("xai-test-key".to_string()),
666 base_url: Some(format!("{}/v1", server.uri())),
667 model: Some("grok-4.5".to_string()),
668 ..ProviderConfig::default()
669 },
670 ..ProvidersConfig::default()
671 }),
672 runtime_chat_isolated: isolated,
673 runtime_thread_inference_unrelated: unrelated,
674 ..Config::default()
675 };
676 ProviderNativeSearchClient::new(CodewhaleClient::new(&config).expect("test xAI client"))
677 .expect("xAI native adapter")
678 }
679
680 #[test]
681 fn responses_payload_requires_search_and_keeps_domains_provider_side() {
682 let body =
683 build_responses_search_body("gpt-5.6", &request(), ResponsesSearchDialect::Openai);
684 assert_eq!(body["tools"][0]["type"], "web_search");
685 assert_eq!(
686 body["tools"][0]["filters"]["allowed_domains"][0],
687 "example.com"
688 );
689 assert_eq!(body["tool_choice"], "required");
690 assert_eq!(body["include"][0], "web_search_call.action.sources");
691 }
692
693 #[test]
694 fn modelstudio_payload_uses_required_harness_search_without_filters() {
695 let body = build_responses_search_body(
696 "qwen3.8-max",
697 &request(),
698 ResponsesSearchDialect::ModelStudio,
699 );
700 assert_eq!(body["tools"][0]["type"], "web_search");
701 assert!(body["tools"][0].get("filters").is_none());
702 assert_eq!(body["tool_choice"], "required");
703 assert!(body.get("include").is_none());
704 assert!(body.get("store").is_none());
705 }
706
707 #[test]
708 fn deepseek_payload_uses_its_responses_search_contract() {
709 let body = build_responses_search_body(
710 "deepseek-v4-flash",
711 &request(),
712 ResponsesSearchDialect::Deepseek,
713 );
714 assert_eq!(body["tools"][0]["type"], "web_search");
715 assert!(body["tools"][0].get("filters").is_none());
716 assert_eq!(body["tool_choice"]["type"], "web_search");
717 assert!(body.get("include").is_none());
718 assert!(body.get("store").is_none());
719 }
720
721 #[test]
722 fn anthropic_payload_uses_basic_direct_search_contract() {
723 let body = build_anthropic_search_body("claude-opus-4-8", &request(), 2_048);
724 assert_eq!(body["tools"][0]["type"], "web_search_20250305");
725 assert_eq!(body["tools"][0]["max_uses"], 1);
726 assert_eq!(body["tools"][0]["allowed_domains"][0], "example.com");
727 assert_eq!(body["max_tokens"], 2_048);
728
729 let tiny_route = build_anthropic_search_body("claude-opus-4-8", &request(), 128);
730 assert_eq!(tiny_route["max_tokens"], 128);
731 }
732
733 #[test]
734 fn mimo_payload_forces_bounded_web_search_plugin() {
735 let body = build_mimo_search_body("mimo-v2.5-pro", &request());
736 assert_eq!(body["tools"][0]["type"], "web_search");
737 assert_eq!(body["tools"][0]["force_search"], true);
738 assert_eq!(body["tools"][0]["limit"], 3);
739 assert_eq!(body["max_completion_tokens"], 2_048);
740 assert_eq!(body["thinking"]["type"], "disabled");
741 }
742
743 #[test]
744 fn responses_parser_separates_answer_and_deduplicated_citations() {
745 let payload = json!({
746 "output": [
747 {
748 "type": "web_search_call",
749 "action": { "sources": [
750 { "url": "https://example.com/a", "title": "Source A" }
751 ] }
752 },
753 {
754 "type": "message",
755 "content": [{
756 "type": "output_text",
757 "text": "Grounded answer.",
758 "annotations": [
759 { "type": "url_citation", "url": "https://example.com/a", "title": "Source A" },
760 { "type": "url_citation", "url": "https://example.org/b", "title": "Source B" }
761 ]
762 }]
763 }
764 ]
765 });
766 let parsed = parse_responses_search(&payload);
767 assert_eq!(parsed.answer.as_deref(), Some("Grounded answer."));
768 assert_eq!(parsed.citations.len(), 2);
769 assert_eq!(parsed.citations[0].title, "Source A");
770 assert_eq!(parsed.citations[1].url, "https://example.org/b");
771 }
772
773 #[test]
774 fn responses_parser_keeps_final_message_and_opened_pages_only() {
775 let payload = json!({
776 "output": [
777 {
778 "type": "reasoning",
779 "content": [{
780 "type": "reasoning_text",
781 "text": "private analysis https://reasoning.example/ must stay hidden"
782 }]
783 },
784 {
785 "type": "web_search_call",
786 "action": {
787 "type": "open_page",
788 "url": "https://github.com/Hmbown/CodeWhale"
789 }
790 },
791 {
792 "type": "message",
793 "content": [{
794 "type": "output_text",
795 "text": "Official repository: https://github.com/Hmbown/CodeWhale",
796 "annotations": []
797 }]
798 }
799 ]
800 });
801
802 let parsed = parse_responses_search(&payload);
803
804 assert_eq!(
805 parsed.answer.as_deref(),
806 Some("Official repository: https://github.com/Hmbown/CodeWhale")
807 );
808 assert_eq!(parsed.citations.len(), 1);
809 assert_eq!(
810 parsed.citations[0].url,
811 "https://github.com/Hmbown/CodeWhale"
812 );
813 }
814
815 #[test]
816 fn anthropic_parser_keeps_result_metadata_and_cited_text_separate() {
817 let payload = json!({
818 "content": [
819 {
820 "type": "web_search_tool_result",
821 "content": [{
822 "type": "web_search_result",
823 "url": "https://example.com/a",
824 "title": "Source A",
825 "page_age": "July 18, 2026"
826 }]
827 },
828 {
829 "type": "text",
830 "text": "Grounded answer.",
831 "citations": [{
832 "type": "web_search_result_location",
833 "url": "https://example.com/a",
834 "title": "Source A",
835 "cited_text": "Supporting passage"
836 }]
837 }
838 ]
839 });
840 let parsed = parse_anthropic_search(&payload);
841 assert_eq!(parsed.answer.as_deref(), Some("Grounded answer."));
842 assert_eq!(parsed.citations.len(), 1);
843 assert_eq!(
844 parsed.citations[0].published.as_deref(),
845 Some("July 18, 2026")
846 );
847 assert_eq!(
848 parsed.citations[0].snippet.as_deref(),
849 Some("Supporting passage")
850 );
851 }
852
853 #[test]
854 fn mimo_parser_keeps_non_streaming_annotations() {
855 let parsed = parse_mimo_search(&json!({
856 "choices": [{
857 "message": {
858 "content": "Grounded answer.",
859 "annotations": [{
860 "type": "url_citation",
861 "url": "https://example.com/weather",
862 "title": "Weather",
863 "summary": "Forecast",
864 "publish_time": "2026-08-28"
865 }]
866 }
867 }]
868 }));
869 assert_eq!(parsed.answer.as_deref(), Some("Grounded answer."));
870 assert_eq!(parsed.citations.len(), 1);
871 assert_eq!(parsed.citations[0].snippet.as_deref(), Some("Forecast"));
872 assert_eq!(parsed.citations[0].published.as_deref(), Some("2026-08-28"));
873 }
874
875 #[test]
876 fn non_http_citations_are_rejected() {
877 let payload = json!({ "citations": ["javascript:alert(1)"] });
878 assert!(parse_responses_search(&payload).citations.is_empty());
879 }
880
881 #[test]
882 fn answer_links_preserve_mixed_scheme_source_order() {
883 let citations =
884 citations_from_text("First http://legacy.example/a, then https://secure.example/b.");
885 assert_eq!(citations.len(), 2);
886 assert_eq!(citations[0].url, "http://legacy.example/a");
887 assert_eq!(citations[1].url, "https://secure.example/b");
888 }
889
890 #[test]
891 fn answer_links_keep_balanced_parenthesis_segments() {
892 let citations = citations_from_text(
893 "See https://en.wikipedia.org/wiki/Foo_(bar) and https://en.wikipedia.org/wiki/Baz_(qux_(nested)) for details.",
894 );
895 assert_eq!(citations.len(), 2);
896 assert_eq!(
897 citations[0].url, "https://en.wikipedia.org/wiki/Foo_(bar)",
898 "a balanced closing paren is part of the URL"
899 );
900 assert_eq!(
901 citations[1].url, "https://en.wikipedia.org/wiki/Baz_(qux_(nested))",
902 "nested balanced parens stay intact"
903 );
904
905 let unbalanced = citations_from_text("Broken https://en.wikipedia.org/wiki/Foo_(bar here.");
906 assert_eq!(
907 unbalanced[0].url, "https://en.wikipedia.org/wiki/Foo_(bar",
908 "an unclosed paren cannot extend past the next whitespace"
909 );
910 }
911
912 #[tokio::test]
913 async fn xai_adapter_reuses_active_authenticated_transport() {
914 let server = MockServer::start().await;
915 Mock::given(method("POST"))
916 .and(path("/v1/responses"))
917 .and(header("authorization", "Bearer xai-test-key"))
918 .and(body_partial_json(json!({
919 "model": "grok-4.5",
920 "tools": [{
921 "type": "web_search",
922 "filters": { "allowed_domains": ["example.com"] }
923 }],
924 "tool_choice": "required"
925 })))
926 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
927 "output_text": "Grounded answer.",
928 "citations": ["https://example.com/source"]
929 })))
930 .expect(1)
931 .mount(&server)
932 .await;
933 let config = Config {
934 provider: Some("xai".to_string()),
935 providers: Some(ProvidersConfig {
936 xai: ProviderConfig {
937 api_key: Some("xai-test-key".to_string()),
938 base_url: Some(format!("{}/v1", server.uri())),
939 model: Some("grok-4.5".to_string()),
940 ..ProviderConfig::default()
941 },
942 ..ProvidersConfig::default()
943 }),
944 ..Config::default()
945 };
946 let inner = CodewhaleClient::new(&config).expect("test xAI client");
947 let client = ProviderNativeSearchClient::new(inner).expect("xAI native adapter");
948 let cache_identity = client.cache_identity();
949 assert!(cache_identity.contains("provider-native://xai/"));
950 assert!(cache_identity.ends_with("/grok-4.5"));
951 assert!(!cache_identity.contains("xai-test-key"));
952
953 let response = client.search(&request()).await.expect("native search");
954
955 assert_eq!(response.answer.as_deref(), Some("Grounded answer."));
956 assert_eq!(response.citations.len(), 1);
957 assert_eq!(response.citations[0].url, "https://example.com/source");
958 }
959
960 #[tokio::test]
961 async fn modelstudio_adapter_uses_token_plan_responses_contract() {
962 let server = MockServer::start().await;
963 Mock::given(method("POST"))
964 .and(path("/v1/responses"))
965 .and(header("authorization", "Bearer modelstudio-test-key"))
966 .and(body_partial_json(json!({
967 "model": "qwen3.8-max",
968 "tools": [{ "type": "web_search" }],
969 "tool_choice": "required"
970 })))
971 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
972 "output": [{
973 "type": "web_search_call",
974 "action": {
975 "sources": [{
976 "url": "https://example.com/qwen",
977 "title": "Qwen source"
978 }]
979 }
980 }]
981 })))
982 .expect(1)
983 .mount(&server)
984 .await;
985 let config = Config {
986 provider: Some("modelstudio-token-plan".to_string()),
987 providers: Some(ProvidersConfig {
988 modelstudio_token_plan: ProviderConfig {
989 api_key: Some("modelstudio-test-key".to_string()),
990 base_url: Some(format!("{}/v1", server.uri())),
991 model: Some("qwen3.8-max".to_string()),
992 ..ProviderConfig::default()
993 },
994 ..ProvidersConfig::default()
995 }),
996 ..Config::default()
997 };
998 let inner = CodewhaleClient::new(&config).expect("test ModelStudio client");
999 let client = ProviderNativeSearchClient::new(inner).expect("Qwen native adapter");
1000
1001 let response = client.search(&request()).await.expect("native search");
1002
1003 assert_eq!(response.citations.len(), 1);
1004 assert_eq!(response.citations[0].url, "https://example.com/qwen");
1005 }
1006 #[tokio::test]
1007 async fn deepseek_adapter_uses_authenticated_responses_endpoint() {
1008 let server = MockServer::start().await;
1009 Mock::given(method("POST"))
1010 .and(path("/v1/responses"))
1011 .and(header("authorization", "Bearer deepseek-test-key"))
1012 .and(body_partial_json(json!({
1013 "model": "deepseek-v4-flash",
1014 "tools": [{ "type": "web_search" }],
1015 "tool_choice": { "type": "web_search" }
1016 })))
1017 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1018 "output": [
1019 {
1020 "type": "web_search_call",
1021 "action": {
1022 "type": "open_page",
1023 "url": "https://example.com/deepseek"
1024 }
1025 },
1026 {
1027 "type": "message",
1028 "content": [{
1029 "type": "output_text",
1030 "text": "Grounded answer.",
1031 "annotations": []
1032 }]
1033 }
1034 ]
1035 })))
1036 .expect(1)
1037 .mount(&server)
1038 .await;
1039 let config = Config {
1040 provider: Some("deepseek".to_string()),
1041 providers: Some(ProvidersConfig {
1042 deepseek: ProviderConfig {
1043 api_key: Some("deepseek-test-key".to_string()),
1044 base_url: Some(format!("{}/v1", server.uri())),
1045 model: Some("deepseek-v4-flash".to_string()),
1046 ..ProviderConfig::default()
1047 },
1048 ..ProvidersConfig::default()
1049 }),
1050 ..Config::default()
1051 };
1052 let inner = CodewhaleClient::new(&config).expect("test DeepSeek client");
1053 let client = ProviderNativeSearchClient::new(inner).expect("DeepSeek native adapter");
1054
1055 let response = client.search(&request()).await.expect("native search");
1056
1057 assert_eq!(response.answer.as_deref(), Some("Grounded answer."));
1058 assert_eq!(response.citations.len(), 1);
1059 assert_eq!(response.citations[0].url, "https://example.com/deepseek");
1060 }
1061
1062 #[tokio::test]
1063 async fn native_search_obeys_attached_run_ownership_without_blocking_unrelated_runtime() {
1064 let server = MockServer::start().await;
1065 Mock::given(method("POST"))
1066 .and(path("/v1/responses"))
1067 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1068 "output_text": "Grounded answer.",
1069 "citations": ["https://example.com/source"]
1070 })))
1071 .expect(3)
1072 .mount(&server)
1073 .await;
1074 let participant = xai_client_with_boundary(&server, false, false);
1075 let isolated = xai_client_with_boundary(&server, true, false);
1076 let unrelated = xai_client_with_boundary(&server, false, true);
1077
1078 let ownership = crate::client::acquire_runtime_chat_inference_ownership().await;
1079 let participant_request = request();
1080 let mut waiting =
1081 tokio::spawn(async move { participant.search(&participant_request).await });
1082 assert!(
1083 tokio::time::timeout(std::time::Duration::from_millis(40), &mut waiting)
1084 .await
1085 .is_err(),
1086 "provider-native inference from the attached run must wait behind Runtime Chat"
1087 );
1088
1089 tokio::time::timeout(
1090 std::time::Duration::from_secs(1),
1091 isolated.search(&request()),
1092 )
1093 .await
1094 .expect("isolated relay request must not self-deadlock")
1095 .expect("isolated native search fixture");
1096 tokio::time::timeout(
1097 std::time::Duration::from_secs(1),
1098 unrelated.search(&request()),
1099 )
1100 .await
1101 .expect("unrelated Runtime manager stays concurrent")
1102 .expect("unrelated native search fixture");
1103
1104 drop(ownership);
1105 tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
1106 .await
1107 .expect("attached participant resumes after relay settlement")
1108 .expect("participant task")
1109 .expect("participant native search fixture");
1110 }
1111 }
1112
1112 lines RUST