1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::app::AgentEvent;
12use crate::cli::ReasoningEffort;
13use crate::providers::{
14 self, KnownModel, ProviderError, ProviderHttpClient, ProviderMessage, Result, StreamFormat, StreamingProvider,
15 StreamingRequest,
16};
17use crate::thndrs_core::auth;
18
19pub const BASE_URL: &str = "https://api.code.umans.ai";
21
22pub const ANTHROPIC_VERSION: &str = "2023-06-01";
24
25pub const API_KEY_ENV: &str = "UMANS_API_KEY";
27
28pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
29
30#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
37pub struct ModelInfo {
38 #[serde(default)]
39 pub name: String,
40 #[serde(default)]
41 pub display_name: String,
42 #[serde(default)]
43 pub description: String,
44 #[serde(default)]
45 pub base_model: BaseModel,
46 #[serde(default)]
47 pub capabilities: Capabilities,
48 #[serde(default)]
49 pub benchmarks: serde_json::Value,
50}
51
52#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
54pub struct BaseModel {
55 #[serde(default)]
56 pub name: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub provider: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub family: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub oss_base: Option<String>,
63}
64
65pub struct UmansClient {
67 http: ProviderHttpClient,
68}
69
70impl UmansClient {
71 pub fn from_env_or_dotenv(workspace_root: &Path) -> Result<Self> {
73 if let Some((api_key, source)) = auth::resolve_credential(API_KEY_ENV, workspace_root) {
74 tracing::debug!(source = source.label(), "loaded Umans API key");
75 Ok(Self::new(BASE_URL, &api_key))
76 } else {
77 tracing::error!(env = API_KEY_ENV, cwd = %workspace_root.display(), "missing Umans API key");
78 Err(ProviderError::missing_api_key(API_KEY_ENV))
79 }
80 }
81
82 pub fn new(base_url: &str, api_key: &str) -> Self {
84 UmansClient { http: ProviderHttpClient::new(base_url, api_key) }
85 }
86
87 pub fn fetch_models_info(&self) -> Result<HashMap<String, ModelInfo>> {
89 let url = format!("{}/v1/models/info", self.http.base_url());
90 let mut resp = self
91 .http
92 .agent()
93 .get(&url)
94 .header("x-api-key", self.http.api_key())
95 .header("anthropic-version", ANTHROPIC_VERSION)
96 .config()
97 .http_status_as_error(false)
98 .build()
99 .call()
100 .map_err(|e| ProviderError::Http(e.to_string()))?;
101
102 let status = resp.status().as_u16();
103 let body = resp
104 .body_mut()
105 .read_to_string()
106 .map_err(|e| ProviderError::Http(e.to_string()))?;
107 if !(200..=299).contains(&status) {
108 return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
109 }
110
111 parse_models_info(&body)
112 }
113
114 pub fn build_messages_request_body(
124 model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
125 ) -> serde_json::Value {
126 providers::anthropic::build_messages_request_body(model, messages, max_tokens, stream, tools)
127 }
128
129 pub fn build_messages_request_body_with_reasoning(
131 model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
132 effort: ReasoningEffort,
133 ) -> Result<serde_json::Value> {
134 if !reasoning_options(model).contains(&effort) {
135 return Err(ProviderError::Json(format!(
136 "reasoning control `{}` is not supported by {model}",
137 effort.label()
138 )));
139 }
140 let mut body = Self::build_messages_request_body(model, messages, max_tokens, stream, tools);
141 match effort {
142 ReasoningEffort::On => body["thinking"] = serde_json::json!({ "type": "enabled" }),
143 ReasoningEffort::None => body["thinking"] = serde_json::json!({ "type": "disabled" }),
144 ReasoningEffort::Auto => {}
145 other => {
146 return Err(ProviderError::Json(format!(
147 "reasoning control `{}` cannot be lowered for Umans",
148 other.label()
149 )));
150 }
151 }
152 Ok(body)
153 }
154
155 pub fn build_headers(&self) -> Vec<(String, String)> {
157 vec![
158 ("x-api-key".to_string(), self.http.api_key().to_string()),
159 ("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string()),
160 ("Content-Type".to_string(), "application/json".to_string()),
161 ]
162 }
163
164 pub fn send_streaming_request(
175 &self, model: &str, messages: &[ProviderMessage], max_tokens: u32, tools: Option<&serde_json::Value>,
176 effort: ReasoningEffort,
177 ) -> Result<ureq::http::Response<ureq::Body>> {
178 let url = format!("{}/v1/messages", self.http.base_url());
179 let body = Self::build_messages_request_body_with_reasoning(model, messages, max_tokens, true, tools, effort)?;
180 let tool_count = tools.and_then(|t| t.as_array()).map_or(0, Vec::len);
181 tracing::info!(
182 model,
183 max_tokens,
184 messages = messages.len(),
185 tools = tool_count,
186 "sending Umans streaming request"
187 );
188
189 let mut request = self.http.agent().post(&url);
190 for (key, value) in self.build_headers() {
191 request = request.header(&key, &value);
192 }
193
194 let mut response = request
195 .config()
196 .http_status_as_error(false)
197 .build()
198 .send_json(&body)
199 .map_err(|e| {
200 tracing::error!(error = %e, "Umans request failed before HTTP response");
201 ProviderError::Http(e.to_string())
202 })?;
203
204 let status = response.status().as_u16();
205 if !(200..=299).contains(&status) {
206 let body = response
207 .body_mut()
208 .read_to_string()
209 .unwrap_or_else(|e| format!("failed to read error body: {e}"));
210 let body = providers::summarize_error_body(&body);
211 tracing::error!(status, error = %body, "Umans request returned non-success status");
212 return Err(ProviderError::Status { code: status, body });
213 }
214
215 tracing::info!(status, "Umans streaming request connected");
216 Ok(response)
217 }
218}
219
220impl StreamingProvider for UmansClient {
221 type Metadata = HashMap<String, ModelInfo>;
222
223 fn name(&self) -> &'static str {
224 "Umans"
225 }
226
227 fn load_status(&self) -> String {
228 String::from("provider: loading UMANS_API_KEY")
229 }
230
231 fn request_status(&self, model: &str) -> String {
232 format!("provider: POST /v1/messages model={model}")
233 }
234
235 fn from_env_or_dotenv(root: &Path) -> Result<Self> {
236 UmansClient::from_env_or_dotenv(root)
237 }
238
239 fn load_metadata(&self) -> Result<Self::Metadata> {
240 self.fetch_models_info()
241 }
242
243 fn metadata_loaded_event(&self, metadata: &Self::Metadata) -> Option<AgentEvent> {
244 let mut items = model_picker_items(metadata);
245 items.extend(
246 providers::opencode::known_models()
247 .into_iter()
248 .map(|model| (model.id.to_string(), model.description.to_string())),
249 );
250 items.extend(
251 providers::chatgpt_codex::known_models()
252 .into_iter()
253 .map(|model| (model.id.to_string(), model.description.to_string())),
254 );
255 Some(AgentEvent::ModelMetadataLoaded(items))
256 }
257
258 fn metadata_status(&self, model: &str, metadata: &Self::Metadata) -> Option<String> {
259 model_status(model, metadata)
260 }
261
262 fn token_budget(&self, model: &str, metadata: Option<&Self::Metadata>) -> u32 {
263 recommended_max_tokens_for_model(model, metadata)
264 }
265
266 fn serialized_request_body(
267 &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
268 ) -> Result<Vec<u8>> {
269 let body = UmansClient::build_messages_request_body_with_reasoning(
270 model,
271 messages,
272 request.max_tokens,
273 true,
274 Some(request.tools),
275 request.reasoning_effort,
276 )?;
277 providers::serialize_request_body(&body)
278 }
279
280 fn send_streaming_request(
281 &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
282 ) -> Result<ureq::http::Response<ureq::Body>> {
283 UmansClient::send_streaming_request(
284 self,
285 model,
286 messages,
287 request.max_tokens,
288 Some(request.tools),
289 request.reasoning_effort,
290 )
291 }
292
293 fn stream_format(&self, _model: &str) -> Result<StreamFormat> {
294 Ok(StreamFormat::AnthropicMessages)
295 }
296
297 fn request_error_message(error: &ProviderError) -> String {
298 match error_to_agent_event(error) {
299 AgentEvent::Failed(msg) => msg,
300 _ => error.to_string(),
301 }
302 }
303
304 fn is_retryable_request_error(error: &ProviderError) -> bool {
305 is_retryable_error(error)
306 }
307}
308
309#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
311pub struct Capabilities {
312 #[serde(default)]
313 pub max_completion_tokens: u64,
314 #[serde(default)]
315 pub recommended_max_tokens: u64,
316 #[serde(default)]
317 pub context_window: u64,
318 #[serde(default)]
319 pub supports_vision: serde_json::Value,
320 #[serde(default)]
321 pub supports_tools: bool,
322 #[serde(default)]
323 pub reasoning: Reasoning,
324}
325
326#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
328pub struct Reasoning {
329 #[serde(default)]
330 pub supported: bool,
331 #[serde(default)]
332 pub can_disable: bool,
333 #[serde(default)]
334 pub levels: Vec<String>,
335 #[serde(default)]
336 pub default_level: Option<String>,
337}
338
339pub fn recommended_max_tokens_for_model(model: &str, models: Option<&HashMap<String, ModelInfo>>) -> u32 {
343 models
344 .and_then(|models| models.get(model))
345 .and_then(|info| u32::try_from(info.capabilities.recommended_max_tokens).ok())
346 .filter(|tokens| *tokens > 0)
347 .unwrap_or(DEFAULT_RECOMMENDED_MAX_TOKENS)
348}
349
350pub fn is_retryable_error(err: &ProviderError) -> bool {
351 err.is_retryable()
352}
353
354pub fn parse_models_info(body: &str) -> Result<HashMap<String, ModelInfo>> {
361 let value: serde_json::Value = serde_json::from_str(body).map_err(|e| ProviderError::Json(e.to_string()))?;
362 let catalogue = value.get("models").or_else(|| value.get("data")).unwrap_or(&value);
363
364 let mut models = match catalogue {
365 serde_json::Value::Object(entries) => entries
366 .iter()
367 .filter_map(|(id, value)| {
368 serde_json::from_value::<ModelInfo>(value.clone())
369 .ok()
370 .map(|info| (id.clone(), info))
371 })
372 .collect::<HashMap<_, _>>(),
373 serde_json::Value::Array(entries) => entries
374 .iter()
375 .filter_map(|value| {
376 let info = serde_json::from_value::<ModelInfo>(value.clone()).ok()?;
377 let id = if info.name.trim().is_empty() { info.base_model.name.clone() } else { info.name.clone() };
378 (!id.trim().is_empty()).then_some((id, info))
379 })
380 .collect::<HashMap<_, _>>(),
381 _ => {
382 return Err(ProviderError::Json(
383 "model catalogue must be an object or array".to_string(),
384 ));
385 }
386 };
387
388 for (id, info) in &mut models {
389 if info.name.trim().is_empty() {
390 info.name.clone_from(id);
391 }
392 if info.display_name.trim().is_empty() {
393 info.display_name.clone_from(&info.name);
394 }
395 if info.base_model.name.trim().is_empty() {
396 info.base_model.name.clone_from(&info.name);
397 }
398 }
399 Ok(models)
400}
401
402pub fn known_models() -> Vec<KnownModel> {
407 vec![
408 KnownModel { id: "umans-coder", description: "Recommended route, currently Kimi K2.7-Code" },
409 KnownModel { id: "umans-kimi-k2.7", description: "Hard coding tasks, always-on reasoning" },
410 KnownModel { id: "umans-glm-5.2", description: "Latest GLM, largest context window" },
411 KnownModel { id: "umans-flash", description: "Fast light model for context and summaries" },
412 ]
413}
414
415pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
417 match model {
418 "umans-glm-5.2" | "umans-glm-5.1" | "umans-flash" | "umans-qwen3.6-35b-a3b" => {
419 vec![ReasoningEffort::Auto, ReasoningEffort::On, ReasoningEffort::None]
420 }
421 _ => vec![ReasoningEffort::Auto],
422 }
423}
424
425pub fn model_picker_items(models: &HashMap<String, ModelInfo>) -> Vec<(String, String)> {
426 let mut items: Vec<(String, String)> = models
427 .iter()
428 .map(|(id, info)| (id.clone(), model_picker_detail(info)))
429 .collect();
430 items.sort_by(|left, right| left.0.cmp(&right.0));
431 items
432}
433
434pub fn model_status(model: &str, models: &HashMap<String, ModelInfo>) -> Option<String> {
435 models
436 .get(model)
437 .map(|info| format!("model: {} {}", info.display_name, model_picker_detail(info)))
438}
439
440pub fn error_to_agent_event(err: &ProviderError) -> AgentEvent {
447 AgentEvent::Failed(actionable_error_message(err))
448}
449
450fn actionable_error_message(err: &ProviderError) -> String {
451 match err {
452 ProviderError::MissingApiKey { .. } => err.to_string(),
453 ProviderError::Status { code, body } => match code {
454 401 | 403 => {
455 format!("Umans authentication failed (HTTP {code}); check UMANS_API_KEY or run `thndrs login umans`")
456 }
457 429 => "Umans rate limit exceeded; check your Umans usage/plan, then retry".to_string(),
458 500..=599 => format!("Umans server error (HTTP {code}): {body}; retry shortly"),
459 _ => format!("Umans request failed (HTTP {code}): {body}"),
460 },
461 ProviderError::Auth(message) => {
462 format!("Umans authentication failed: {message}; check UMANS_API_KEY or run `thndrs login umans`")
463 }
464 ProviderError::AuthUnavailable(message) => {
465 format!("Umans authentication verification unavailable: {message}; retry shortly")
466 }
467 ProviderError::Http(message) => {
468 format!("Umans network error: {message}; check your connection and retry")
469 }
470 ProviderError::Json(message) => {
471 format!("Umans response parse error: {message}; retry or check the Umans service status")
472 }
473 ProviderError::InvalidModelId { model, .. } => {
474 format!("Umans model `{model}` is not valid; choose a current Umans model with `/model`")
475 }
476 }
477}
478
479fn model_picker_detail(info: &ModelInfo) -> String {
480 let provider = info
481 .base_model
482 .provider
483 .as_deref()
484 .or(info.base_model.family.as_deref())
485 .unwrap_or(info.base_model.name.as_str());
486 let tools = if info.capabilities.supports_tools { "tools" } else { "no tools" };
487 let reasoning = if info.capabilities.reasoning.supported { "reasoning" } else { "no reasoning" };
488 format!(
489 "{} · ctx {} · out {} · {} · {}",
490 provider,
491 compact_token_count(info.capabilities.context_window),
492 compact_token_count(info.capabilities.recommended_max_tokens),
493 tools,
494 reasoning
495 )
496}
497
498fn compact_token_count(tokens: u64) -> String {
499 const K: u64 = 1024;
500 const M: u64 = K * K;
501
502 if tokens >= M && tokens.is_multiple_of(M) {
503 format!("{}M", tokens / M)
504 } else if tokens >= K && tokens.is_multiple_of(K) {
505 format!("{}k", tokens / K)
506 } else {
507 tokens.to_string()
508 }
509}
510
511pub fn validate_api_key(api_key: &str) -> std::result::Result<(), String> {
513 probe_api_key(api_key).map_err(|error| format!("validation failed: {error}"))
514}
515
516pub fn probe_api_key(api_key: &str) -> Result<()> {
517 validate_api_key_at(BASE_URL, api_key)
518}
519
520fn validate_api_key_at(base_url: &str, api_key: &str) -> Result<()> {
521 UmansClient::new(base_url, api_key).fetch_models_info().map(|_| ())
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::providers::anthropic::{SseEvent, parse_sse_chunk, parse_sse_event, sse_to_agent_event};
528 use crate::providers::{ProviderContentBlock, ProviderMessage, ProviderMessageContent};
529 use std::env;
530 use std::io::{Read, Write};
531 use std::net::TcpListener;
532 use std::thread;
533
534 fn mock_models_info_server(body: &'static str) -> String {
535 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
536 let addr = listener.local_addr().expect("local addr");
537 thread::spawn(move || {
538 let (mut stream, _) = listener.accept().expect("accept request");
539 let mut request = [0_u8; 1024];
540 let count = stream.read(&mut request).expect("read request");
541 let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase();
542 assert!(request.contains("x-api-key: sk-validation"));
543 let response = format!(
544 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
545 body.len(),
546 body
547 );
548 stream.write_all(response.as_bytes()).expect("write response");
549 });
550 format!("http://{addr}")
551 }
552
553 fn mock_models_info_response_server(status: &'static str, body: &'static str) -> String {
554 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
555 let addr = listener.local_addr().expect("local addr");
556 thread::spawn(move || {
557 let (mut stream, _) = listener.accept().expect("accept request");
558 let mut request = [0_u8; 1024];
559 let _ = stream.read(&mut request);
560 let response = format!(
561 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
562 body.len(),
563 body
564 );
565 stream.write_all(response.as_bytes()).expect("write response");
566 });
567 format!("http://{addr}")
568 }
569
570 #[test]
571 fn build_messages_request_body_has_required_fields() {
572 let messages = vec![ProviderMessage::user("Hello!")];
573 let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, None);
574 assert_eq!(body["model"], "umans-coder");
575 assert_eq!(body["max_tokens"], 4096);
576 assert_eq!(body["stream"], true);
577 assert_eq!(body["messages"][0]["role"], "user");
578 assert_eq!(body["messages"][0]["content"], "Hello!");
579 assert!(body.get("tools").is_none(), "no tools when None passed");
580 }
581
582 #[test]
583 fn build_messages_request_body_non_stream() {
584 let messages = vec![ProviderMessage::user("test")];
585 let body = UmansClient::build_messages_request_body("umans-glm-5.2", &messages, 8192, false, None);
586 assert_eq!(body["model"], "umans-glm-5.2");
587 assert_eq!(body["stream"], false);
588 }
589
590 #[test]
591 fn build_messages_request_body_includes_tools() {
592 let messages = vec![ProviderMessage::user("find files")];
593 let defs = crate::tools::tool_definitions();
594 let catalog = crate::tools::tool_catalog_schemas(&defs);
595 let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, Some(&catalog));
596
597 let request_names: Vec<&str> = body["tools"]
598 .as_array()
599 .expect("tools should be present")
600 .iter()
601 .map(|tool| tool["name"].as_str().expect("tool name"))
602 .collect();
603 let definition_names: Vec<&str> = defs.iter().map(|definition| definition.name.as_ref()).collect();
604
605 assert_eq!(request_names, definition_names);
606 }
607
608 #[test]
609 fn build_messages_request_body_omits_empty_tools() {
610 let messages = vec![ProviderMessage::user("hi")];
611 let empty_tools = serde_json::json!([]);
612 let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, Some(&empty_tools));
613 assert!(body.get("tools").is_none(), "empty tool array should be omitted");
614 }
615
616 #[test]
617 fn recommended_max_tokens_prefers_live_metadata_with_default_fallback() {
618 let json = include_str!("./fixtures/model_info.json");
619 let mut models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
620 let mut high_budget = models["umans-coder"].clone();
621 high_budget.capabilities.recommended_max_tokens = 131_071;
622 models.insert("metadata-high-budget".to_string(), high_budget);
623
624 assert_eq!(recommended_max_tokens_for_model("umans-coder", Some(&models)), 32_768);
625 assert_eq!(
626 recommended_max_tokens_for_model("metadata-high-budget", Some(&models)),
627 131_071
628 );
629 assert_eq!(
630 recommended_max_tokens_for_model("unknown-model", Some(&models)),
631 DEFAULT_RECOMMENDED_MAX_TOKENS
632 );
633 assert_eq!(
634 recommended_max_tokens_for_model("umans-glm-5.2", None),
635 DEFAULT_RECOMMENDED_MAX_TOKENS
636 );
637 }
638
639 #[test]
640 fn model_metadata_formats_picker_items_and_status() {
641 let json = include_str!("./fixtures/model_info.json");
642 let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
643
644 let items = model_picker_items(&models);
645 let coder = items
646 .iter()
647 .find(|(id, _)| id == "umans-coder")
648 .expect("umans-coder item");
649 assert!(coder.1.contains("ctx 256k"));
650 assert!(coder.1.contains("out 32k"));
651 assert!(coder.1.contains("tools"));
652
653 let status = model_status("umans-coder", &models).expect("model status");
654 assert!(status.starts_with("model: "));
655 assert!(status.contains("ctx 256k"));
656 assert!(model_status("unknown-model", &models).is_none());
657 }
658
659 #[test]
660 fn retryable_error_classification_matches_policy() {
661 assert!(!is_retryable_error(&ProviderError::missing_api_key(API_KEY_ENV)));
662 assert!(!is_retryable_error(&ProviderError::Status {
663 code: 400,
664 body: "bad request".into()
665 }));
666 assert!(!is_retryable_error(&ProviderError::Status {
667 code: 401,
668 body: "unauthorized".into()
669 }));
670 assert!(is_retryable_error(&ProviderError::Status {
671 code: 429,
672 body: "too many requests".into()
673 }));
674 assert!(is_retryable_error(&ProviderError::Status {
675 code: 503,
676 body: "service unavailable".into()
677 }));
678 assert!(is_retryable_error(&ProviderError::Http("connection reset".into())));
679 assert!(!is_retryable_error(&ProviderError::Http("request aborted".into())));
680 }
681
682 #[test]
683 fn build_headers_include_api_key_and_version() {
684 let client = UmansClient::new(BASE_URL, "sk-test-key");
685 let headers = client.build_headers();
686 let header_map: HashMap<String, String> = headers.into_iter().collect();
687 assert_eq!(header_map.get("x-api-key").unwrap(), "sk-test-key");
688 assert_eq!(header_map.get("anthropic-version").unwrap(), ANTHROPIC_VERSION);
689 assert_eq!(header_map.get("Content-Type").unwrap(), "application/json");
690 assert!(
691 !header_map
692 .keys()
693 .any(|key| key.eq_ignore_ascii_case("x-umans-websearch-provider"))
694 );
695 }
696
697 #[test]
698 fn from_env_or_dotenv_missing_key_returns_error() {
699 let _guard = crate::test_env::lock();
700 unsafe {
701 env::remove_var(API_KEY_ENV);
702 }
703 let dir = tempfile::tempdir().unwrap();
704 let result = UmansClient::from_env_or_dotenv(dir.path());
705 assert!(matches!(result, Err(ProviderError::MissingApiKey { env }) if env == API_KEY_ENV));
706 }
707
708 #[test]
709 fn from_env_or_dotenv_reads_workspace_env_file() {
710 let _guard = crate::test_env::lock();
711 unsafe {
712 env::remove_var(API_KEY_ENV);
713 }
714 let dir = tempfile::tempdir().unwrap();
715 std::fs::write(dir.path().join(".env"), "UMANS_API_KEY=sk-dotenv-key\n").unwrap();
716
717 let client = UmansClient::from_env_or_dotenv(dir.path()).unwrap();
718 let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
719
720 assert_eq!(headers.get("x-api-key").unwrap(), "sk-dotenv-key");
721 }
722
723 #[test]
724 fn from_env_or_dotenv_reads_exported_quoted_env_file_value() {
725 let _guard = crate::test_env::lock();
726 unsafe {
727 env::remove_var(API_KEY_ENV);
728 }
729 let dir = tempfile::tempdir().unwrap();
730 std::fs::write(
731 dir.path().join(".env"),
732 "export UMANS_API_KEY=\"sk-quoted-dotenv-key\"\n",
733 )
734 .unwrap();
735
736 let client = UmansClient::from_env_or_dotenv(dir.path()).unwrap();
737 let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
738
739 assert_eq!(headers.get("x-api-key").unwrap(), "sk-quoted-dotenv-key");
740 }
741
742 #[test]
743 fn from_env_or_dotenv_reads_global_credential_store() {
744 let _guard = crate::test_env::lock();
745 unsafe {
746 env::remove_var(API_KEY_ENV);
747 }
748 let dir = tempfile::tempdir().unwrap();
749 let home = dir.path().join("home");
750 let workspace = dir.path().join("workspace");
751 std::fs::create_dir_all(home.join(".thndrs")).unwrap();
752 std::fs::create_dir_all(&workspace).unwrap();
753 std::fs::write(
754 home.join(".thndrs").join("credentials.env"),
755 "UMANS_API_KEY=sk-global-key\n",
756 )
757 .unwrap();
758
759 let old_home = env::var_os("HOME");
760 unsafe { env::set_var("HOME", &home) };
761 let client = UmansClient::from_env_or_dotenv(&workspace).unwrap();
762 unsafe {
763 if let Some(home) = old_home {
764 env::set_var("HOME", home);
765 } else {
766 env::remove_var("HOME");
767 }
768 }
769
770 let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
771 assert_eq!(headers.get("x-api-key").unwrap(), "sk-global-key");
772 }
773
774 #[test]
775 fn from_env_or_dotenv_reads_project_credential_store() {
776 let _guard = crate::test_env::lock();
777 unsafe {
778 env::remove_var(API_KEY_ENV);
779 }
780 let dir = tempfile::tempdir().unwrap();
781 let home = dir.path().join("home");
782 let workspace = dir.path().join("workspace");
783 std::fs::create_dir_all(&home).unwrap();
784 std::fs::create_dir_all(workspace.join(".thndrs")).unwrap();
785 std::fs::write(
786 workspace.join(".thndrs").join("credentials.env"),
787 "UMANS_API_KEY=sk-project-key\n",
788 )
789 .unwrap();
790
791 let old_home = env::var_os("HOME");
792 unsafe { env::set_var("HOME", &home) };
793 let client = UmansClient::from_env_or_dotenv(&workspace).unwrap();
794 unsafe {
795 if let Some(home) = old_home {
796 env::set_var("HOME", home);
797 } else {
798 env::remove_var("HOME");
799 }
800 }
801
802 let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
803 assert_eq!(headers.get("x-api-key").unwrap(), "sk-project-key");
804 }
805
806 #[test]
807 fn missing_key_error_includes_setup_hint() {
808 let _guard = crate::test_env::lock();
809 unsafe {
810 env::remove_var(API_KEY_ENV);
811 }
812 let dir = tempfile::tempdir().unwrap();
813 let result = match UmansClient::from_env_or_dotenv(dir.path()) {
814 Ok(_) => panic!("missing key should fail"),
815 Err(err) => err,
816 };
817 let message = result.to_string();
818 assert!(message.contains("thndrs setup"));
819 assert!(message.contains("thndrs login"));
820 assert!(!message.contains("sk-"));
821 }
822
823 #[test]
824 fn validation_does_not_persist_provider_payloads() {
825 let _guard = crate::test_env::lock();
826 let dir = tempfile::tempdir().unwrap();
827 let home = dir.path().join("home");
828 let workspace = dir.path().join("workspace");
829 std::fs::create_dir_all(&home).unwrap();
830 std::fs::create_dir_all(&workspace).unwrap();
831
832 let body = r#"{"umans-coder":{"name":"umans-coder","display_name":"Umans Coder","description":"ok","base_model":{"name":"kimi"},"capabilities":{"max_completion_tokens":1,"recommended_max_tokens":1,"context_window":1,"supports_vision":false,"supports_tools":true,"reasoning":{"supported":false,"can_disable":false}}}}"#;
833 let base_url = mock_models_info_server(body);
834 let old_home = env::var_os("HOME");
835 unsafe { env::set_var("HOME", &home) };
836 validate_api_key_at(&base_url, "sk-validation").expect("validation succeeds");
837 unsafe {
838 if let Some(home) = old_home {
839 env::set_var("HOME", home);
840 } else {
841 env::remove_var("HOME");
842 }
843 }
844
845 assert!(!home.join(".thndrs").join("credentials.env").exists());
846 assert!(!workspace.join(".thndrs").join("credentials.env").exists());
847 }
848
849 #[test]
850 fn validation_preserves_rejected_credential_status() {
851 let base_url = mock_models_info_response_server("401 Unauthorized", r#"{"error":"invalid key"}"#);
852
853 let error = validate_api_key_at(&base_url, "rejected-key").expect_err("credential should be rejected");
854
855 assert!(matches!(error, ProviderError::Status { code: 401, .. }));
856 }
857
858 #[test]
859 fn parse_models_info_fixture() {
860 let json = r#"{
861 "umans-coder": {
862 "name": "umans-coder",
863 "display_name": "Umans Coder",
864 "description": "Recommended model.",
865 "base_model": {"name": "kimi-k2.7-code", "provider": "Moonshot", "oss_base": "Kimi K2.7-Code"},
866 "capabilities": {
867 "max_completion_tokens": 262144,
868 "recommended_max_tokens": 32768,
869 "context_window": 262144,
870 "supports_vision": true,
871 "supports_tools": true,
872 "reasoning": {"supported": true, "can_disable": false, "levels": [], "default_level": null}
873 },
874 "benchmarks": {}
875 },
876 "umans-glm-5.2": {
877 "name": "umans-glm-5.2",
878 "display_name": "Umans GLM 5.2",
879 "description": "Latest GLM.",
880 "base_model": {"name": "GLM-5.2", "family": "GLM"},
881 "capabilities": {
882 "max_completion_tokens": 131072,
883 "recommended_max_tokens": 131071,
884 "context_window": 405504,
885 "supports_vision": "via-handoff",
886 "supports_tools": true,
887 "reasoning": {"supported": true, "can_disable": true, "levels": ["none", "high", "max"], "default_level": "high"}
888 },
889 "benchmarks": {}
890 }
891 }"#;
892
893 let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
894 let coder = models.get("umans-coder").expect("umans-coder present");
895 assert_eq!(coder.display_name, "Umans Coder");
896 assert_eq!(coder.capabilities.context_window, 262144);
897 assert_eq!(coder.capabilities.recommended_max_tokens, 32768);
898 assert!(coder.capabilities.supports_tools);
899 assert!(coder.capabilities.reasoning.supported);
900 assert!(!coder.capabilities.reasoning.can_disable);
901
902 let glm = models.get("umans-glm-5.2").expect("umans-glm-5.2 present");
903 assert_eq!(glm.capabilities.context_window, 405504);
904 assert!(glm.capabilities.reasoning.can_disable);
905 assert_eq!(glm.capabilities.reasoning.default_level.as_deref(), Some("high"));
906 assert_eq!(glm.capabilities.reasoning.levels, vec!["none", "high", "max"]);
907 }
908
909 #[test]
910 fn parse_models_info_from_live_fixture() {
911 let json = include_str!("./fixtures/model_info.json");
912 let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
913 let coder = &models["umans-coder"];
914 assert_eq!(coder.capabilities.context_window, 262144);
915 }
916
917 #[test]
918 fn parse_models_info_accepts_catalogue_wrappers_and_missing_display_fields() {
919 let body = r#"{
920 "data": [
921 {"name":"umans-new-model","base_model":{"name":"new-base"},"capabilities":{"supports_tools":true}}
922 ]
923 }"#;
924
925 let models = parse_models_info(body).expect("wrapped catalogue");
926 let model = models.get("umans-new-model").expect("model id");
927 assert_eq!(model.display_name, "umans-new-model");
928 assert_eq!(model.base_model.name, "new-base");
929 assert!(model.capabilities.supports_tools);
930 assert_eq!(model.capabilities.recommended_max_tokens, 0);
931 }
932
933 #[test]
934 fn parse_sse_event_text_delta() {
935 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
936 let event = parse_sse_event("content_block_delta", data);
937 assert_eq!(event, SseEvent::TextDelta("Hello".to_string()));
938 }
939
940 #[test]
941 fn parse_sse_event_text_delta_strips_think_tags() {
942 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"</think>\nDone"}}"#;
943 let event = parse_sse_event("content_block_delta", data);
944 assert_eq!(event, SseEvent::TextDelta("\nDone".to_string()));
945 }
946
947 #[test]
948 fn parse_sse_event_thinking_delta() {
949 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think..."}}"#;
950 let event = parse_sse_event("content_block_delta", data);
951 assert_eq!(event, SseEvent::ThinkingDelta("Let me think...".to_string()));
952 }
953
954 #[test]
955 fn parse_sse_event_message_start() {
956 let event = parse_sse_event("message_start", "{}");
957 assert_eq!(event, SseEvent::MessageStart);
958 }
959
960 #[test]
961 fn parse_sse_event_content_block_start_thinking() {
962 let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#;
963 let event = parse_sse_event("content_block_start", data);
964 assert_eq!(event, SseEvent::ThinkingStart);
965 }
966
967 #[test]
968 fn parse_sse_event_content_block_start_text() {
969 let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
970 let event = parse_sse_event("content_block_start", data);
971 assert_eq!(event, SseEvent::TextStart);
972 }
973
974 #[test]
975 fn parse_sse_event_message_stop() {
976 let event = parse_sse_event("message_stop", "{}");
977 assert_eq!(event, SseEvent::MessageStop);
978 }
979
980 #[test]
981 fn parse_sse_event_input_json_delta() {
982 let data = serde_json::json!({
983 "type": "content_block_delta",
984 "index": 1,
985 "delta": {
986 "type": "input_json_delta",
987 "partial_json": "{\"pattern\""
988 }
989 })
990 .to_string();
991 let event = parse_sse_event("content_block_delta", &data);
992 assert_eq!(
993 event,
994 SseEvent::InputJsonDelta { index: 1, partial_json: "{\"pattern\"".to_string() }
995 );
996 }
997
998 #[test]
999 fn parse_sse_event_content_block_stop_with_index() {
1000 let data = r#"{"type":"content_block_stop","index":2}"#;
1001 let event = parse_sse_event("content_block_stop", data);
1002 assert_eq!(event, SseEvent::ContentBlockStop { index: Some(2) });
1003 }
1004
1005 #[test]
1006 fn parse_sse_event_error() {
1007 let data = r#"{"type":"error","error":{"type":"overloaded_error","message":"Server overloaded"}}"#;
1008 let event = parse_sse_event("error", data);
1009 assert_eq!(event, SseEvent::Error("Server overloaded".to_string()));
1010 }
1011
1012 #[test]
1013 fn parse_sse_event_message_delta_with_stop_reason() {
1014 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#;
1015 let event = parse_sse_event("message_delta", data);
1016 assert_eq!(
1017 event,
1018 SseEvent::MessageDelta { stop_reason: Some("end_turn".to_string()) }
1019 );
1020 }
1021
1022 #[test]
1023 fn sse_to_agent_text_delta() {
1024 let event = SseEvent::TextDelta("response".to_string());
1025 assert_eq!(
1026 sse_to_agent_event(&event),
1027 Some(AgentEvent::AssistantDelta("response".to_string()))
1028 );
1029 }
1030
1031 #[test]
1032 fn sse_to_agent_thinking_delta() {
1033 let event = SseEvent::ThinkingDelta("reasoning".to_string());
1034 assert_eq!(
1035 sse_to_agent_event(&event),
1036 Some(AgentEvent::ReasoningDelta("reasoning".to_string()))
1037 );
1038 }
1039
1040 #[test]
1041 fn sse_to_agent_message_start() {
1042 assert_eq!(sse_to_agent_event(&SseEvent::MessageStart), Some(AgentEvent::Started));
1043 }
1044
1045 #[test]
1046 fn sse_to_agent_message_stop() {
1047 assert_eq!(sse_to_agent_event(&SseEvent::MessageStop), None);
1048 }
1049
1050 #[test]
1051 fn sse_to_agent_error() {
1052 assert_eq!(
1053 sse_to_agent_event(&SseEvent::Error("fail".to_string())),
1054 Some(AgentEvent::Failed("fail".to_string()))
1055 );
1056 }
1057
1058 #[test]
1059 fn sse_to_agent_content_block_start_no_event() {
1060 assert_eq!(sse_to_agent_event(&SseEvent::TextStart), None);
1061 assert_eq!(sse_to_agent_event(&SseEvent::ThinkingStart), None);
1062 }
1063
1064 #[test]
1065 fn parse_sse_chunk_multiple_events() {
1066 let chunk = "event: message_start\ndata: {}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\nevent: message_stop\ndata: {}\n\n";
1067 let events = parse_sse_chunk(chunk);
1068 assert_eq!(events.len(), 4);
1069 assert_eq!(events[0].0, "message_start");
1070 assert_eq!(events[1].0, "content_block_start");
1071 assert_eq!(events[2].0, "content_block_delta");
1072 assert_eq!(events[3].0, "message_stop");
1073 }
1074
1075 #[test]
1076 fn parse_sse_chunk_empty() {
1077 let events = parse_sse_chunk("");
1078 assert!(events.is_empty());
1079 }
1080
1081 #[test]
1082 fn parse_sse_chunk_no_trailing_newline() {
1083 let chunk = "event: message_stop\ndata: {}";
1084 let events = parse_sse_chunk(chunk);
1085 assert_eq!(events.len(), 1);
1086 assert_eq!(events[0].0, "message_stop");
1087 }
1088
1089 #[test]
1090 fn parse_full_stream_fixture_into_agent_events() {
1091 let sse = include_str!("./fixtures/stream_response.sse");
1092 let chunks = parse_sse_chunk(sse);
1093 let agent_events: Vec<AgentEvent> = chunks
1094 .iter()
1095 .filter_map(|(event_type, data)| {
1096 let sse_event = parse_sse_event(event_type, data);
1097 sse_to_agent_event(&sse_event)
1098 })
1099 .collect();
1100
1101 assert_eq!(agent_events.len(), 3);
1102 assert_eq!(agent_events[0], AgentEvent::Started);
1103 assert_eq!(agent_events[1], AgentEvent::ReasoningDelta("Analyzing...".to_string()));
1104 assert_eq!(agent_events[2], AgentEvent::AssistantDelta("Hello world".to_string()));
1105 }
1106
1107 #[test]
1108 fn error_to_agent_event_missing_api_key() {
1109 let event = error_to_agent_event(&ProviderError::missing_api_key(API_KEY_ENV));
1110 assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("UMANS_API_KEY")));
1111 }
1112
1113 #[test]
1114 fn error_to_agent_event_auth_failure() {
1115 let event = error_to_agent_event(&ProviderError::Status { code: 401, body: "Unauthorized".into() });
1116 assert!(
1117 matches!(event, AgentEvent::Failed(ref msg) if msg.contains("authentication failed") && msg.contains("thndrs login umans"))
1118 );
1119 assert!(!format!("{event:?}").contains("Unauthorized"));
1120 }
1121
1122 #[test]
1123 fn error_to_agent_event_rate_limit() {
1124 let event = error_to_agent_event(&ProviderError::Status { code: 429, body: "Too Many Requests".into() });
1125 assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("rate limit") && msg.contains("usage/plan")));
1126 }
1127
1128 #[test]
1129 fn error_to_agent_event_server_error() {
1130 let event = error_to_agent_event(&ProviderError::Status { code: 500, body: "Internal Server Error".into() });
1131 assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("server error")));
1132 }
1133
1134 #[test]
1135 fn error_to_agent_event_network_error() {
1136 let event = error_to_agent_event(&ProviderError::Http("connection refused".into()));
1137 assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("network error")));
1138 }
1139
1140 #[test]
1141 fn error_to_agent_event_json_error() {
1142 let event = error_to_agent_event(&ProviderError::Json("bad metadata".into()));
1143 assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("response parse error")));
1144 }
1145
1146 #[test]
1147 fn no_network_request_construction() {
1148 let messages = vec![ProviderMessage::user("test"), ProviderMessage::assistant("response")];
1149 let body = UmansClient::build_messages_request_body("umans-coder", &messages, 8192, true, None);
1150 assert_eq!(body["model"], "umans-coder");
1151 assert_eq!(body["messages"].as_array().unwrap().len(), 2);
1152 assert_eq!(body["stream"], true);
1153 }
1154
1155 #[test]
1156 fn no_network_stream_parsing_from_fixture() {
1157 let sse = include_str!("./fixtures/stream_response.sse");
1158 let chunks = parse_sse_chunk(sse);
1159 assert!(!chunks.is_empty());
1160
1161 let events: Vec<SseEvent> = chunks.iter().map(|(et, data)| parse_sse_event(et, data)).collect();
1162 assert!(events.iter().any(|e| matches!(e, SseEvent::ThinkingDelta(_))));
1163 assert!(events.iter().any(|e| matches!(e, SseEvent::TextDelta(_))));
1164 assert!(events.contains(&SseEvent::MessageStart));
1165 assert!(events.contains(&SseEvent::MessageStop));
1166 }
1167
1168 #[test]
1169 fn no_network_metadata_parsing_from_fixture() {
1170 let json = include_str!("./fixtures/model_info.json");
1171 let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
1172 assert!(models.contains_key("umans-coder"));
1173
1174 let coder = &models["umans-coder"];
1175 assert!(coder.capabilities.supports_tools);
1176 assert!(!coder.capabilities.reasoning.can_disable);
1177 }
1178
1179 #[test]
1180 #[ignore = "requires UMANS_API_KEY and network access"]
1181 fn live_models_info() {
1182 let workspace_root = env::current_dir().expect("current dir");
1183 let client = UmansClient::from_env_or_dotenv(&workspace_root).expect("UMANS_API_KEY must be set");
1184 let models = client.fetch_models_info().expect("fetch models info");
1185 assert!(models.contains_key("umans-coder"));
1186 }
1187
1188 #[test]
1189 fn message_user_constructor() {
1190 let msg = ProviderMessage::user("test prompt");
1191 assert_eq!(msg.role, "user");
1192 assert_eq!(msg.as_text(), "test prompt");
1193 }
1194
1195 #[test]
1196 fn message_assistant_constructor() {
1197 let msg = ProviderMessage::assistant("response");
1198 assert_eq!(msg.role, "assistant");
1199 assert_eq!(msg.as_text(), "response");
1200 }
1201
1202 #[test]
1203 fn tool_result_message_has_correct_shape() {
1204 let msg = ProviderMessage::tool_result("toolu_01", "found 2 files", false);
1205 assert_eq!(msg.role, "user");
1206
1207 match &msg.content {
1208 ProviderMessageContent::Blocks(blocks) => {
1209 assert_eq!(blocks.len(), 1);
1210 match &blocks[0] {
1211 ProviderContentBlock::ToolResult { tool_use_id, content, is_error } => {
1212 assert_eq!(tool_use_id, "toolu_01");
1213 assert_eq!(content, "found 2 files");
1214 assert_eq!(*is_error, Some(false));
1215 }
1216 other => panic!("expected ToolResult, got {other:?}"),
1217 }
1218 }
1219 other => panic!("expected Blocks, got {other:?}"),
1220 }
1221 }
1222
1223 #[test]
1224 fn assistant_blocks_message_serializes_tool_use() {
1225 let blocks = vec![ProviderContentBlock::ToolUse {
1226 id: "toolu_01".to_string(),
1227 name: "find_files".to_string(),
1228 input: serde_json::json!({"pattern": "Cargo"}),
1229 }];
1230 let msg = ProviderMessage::assistant_blocks(blocks);
1231 let json = serde_json::to_value(&msg).expect("serialize");
1232 assert_eq!(json["role"], "assistant");
1233
1234 let content = &json["content"];
1235 assert!(content.is_array(), "content should be an array for blocks");
1236 assert_eq!(content[0]["type"], "tool_use");
1237 assert_eq!(content[0]["id"], "toolu_01");
1238 assert_eq!(content[0]["name"], "find_files");
1239 assert_eq!(content[0]["input"]["pattern"], "Cargo");
1240 }
1241
1242 #[test]
1243 fn text_message_content_serializes_as_string() {
1244 let msg = ProviderMessage::user("hello");
1245 let json = serde_json::to_value(&msg).expect("serialize");
1246 assert_eq!(json["content"], "hello");
1247 assert!(json["content"].is_string(), "text content should serialize as a string");
1248 }
1249
1250 #[test]
1251 fn tool_result_block_serializes_with_is_error() {
1252 let msg = ProviderMessage::tool_result("toolu_02", "command failed", true);
1253 let json = serde_json::to_value(&msg).expect("serialize");
1254 let block = &json["content"][0];
1255 assert_eq!(block["type"], "tool_result");
1256 assert_eq!(block["tool_use_id"], "toolu_02");
1257 assert_eq!(block["content"], "command failed");
1258 assert_eq!(block["is_error"], true);
1259 }
1260
1261 #[test]
1262 fn user_image_block_serializes_as_anthropic_content() {
1263 let msg = ProviderMessage::user_blocks(vec![ProviderContentBlock::Image {
1264 source: crate::providers::ProviderImageSource::Base64 {
1265 media_type: "image/png".to_string(),
1266 data: "aGVsbG8=".to_string(),
1267 },
1268 }]);
1269 let json = serde_json::to_value(&msg).expect("serialize");
1270 let block = &json["content"][0];
1271 assert_eq!(block["type"], "image");
1272 assert_eq!(block["source"]["type"], "base64");
1273 assert_eq!(block["source"]["media_type"], "image/png");
1274 assert_eq!(block["source"]["data"], "aGVsbG8=");
1275 }
1276
1277 #[test]
1278 fn assistant_blocks_with_text_and_tool_use_serializes_in_order() {
1279 let blocks = vec![
1280 ProviderContentBlock::Text { text: "Let me search.".to_string() },
1281 ProviderContentBlock::ToolUse {
1282 id: "toolu_03".to_string(),
1283 name: "search_text".to_string(),
1284 input: serde_json::json!({"pattern": "fn main"}),
1285 },
1286 ];
1287 let msg = ProviderMessage::assistant_blocks(blocks);
1288 let json = serde_json::to_value(&msg).expect("serialize");
1289 assert_eq!(json["content"][0]["type"], "text");
1290 assert_eq!(json["content"][0]["text"], "Let me search.");
1291 assert_eq!(json["content"][1]["type"], "tool_use");
1292 assert_eq!(json["content"][1]["id"], "toolu_03");
1293 }
1294
1295 #[test]
1296 fn message_content_round_trips_through_json() {
1297 let original = ProviderMessage::tool_result("toolu_99", "result text", false);
1298 let json = serde_json::to_string(&original).expect("serialize");
1299 let parsed: ProviderMessage = serde_json::from_str(&json).expect("deserialize");
1300 assert_eq!(original, parsed);
1301 }
1302
1303 #[test]
1304 fn reasoning_toggle_is_lowered_only_for_models_that_can_disable() {
1305 let messages = vec![ProviderMessage::user("hello")];
1306 let enabled = UmansClient::build_messages_request_body_with_reasoning(
1307 "umans-flash",
1308 &messages,
1309 128,
1310 true,
1311 None,
1312 ReasoningEffort::On,
1313 )
1314 .expect("enabled body");
1315 assert_eq!(enabled["thinking"]["type"], "enabled");
1316
1317 let disabled = UmansClient::build_messages_request_body_with_reasoning(
1318 "umans-glm-5.2",
1319 &messages,
1320 128,
1321 true,
1322 None,
1323 ReasoningEffort::None,
1324 )
1325 .expect("disabled body");
1326 assert_eq!(disabled["thinking"]["type"], "disabled");
1327
1328 assert!(
1329 UmansClient::build_messages_request_body_with_reasoning(
1330 "umans-coder",
1331 &messages,
1332 128,
1333 true,
1334 None,
1335 ReasoningEffort::None,
1336 )
1337 .is_err()
1338 );
1339 }
1340}