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