Skip to main content

thndrs_lib/core/providers/
umans.rs

1//! Umans Code provider — Anthropic-compatible Messages API.
2//!
3//! Uses `POST /v1/messages` with `x-api-key` and `anthropic-version` headers.
4//! Streaming responses arrive as SSE events and parsed into [`AgentEvent`].
5
6use 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
19/// Umans Code base URL.
20pub const BASE_URL: &str = "https://api.code.umans.ai";
21
22/// Required Anthropic version header value.
23pub const ANTHROPIC_VERSION: &str = "2023-06-01";
24
25/// Environment variable name for the API key.
26pub const API_KEY_ENV: &str = "UMANS_API_KEY";
27
28pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
29
30/// Model information from `GET /v1/models/info`.
31///
32/// Umans may add optional metadata fields as its model catalogue changes. The
33/// provider keeps the fields needed by setup and request lowering, while
34/// tolerating omitted descriptive fields so a catalogue update does not make
35/// an otherwise usable provider unavailable.
36#[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/// Base model descriptor used by model metadata display.
53#[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
65/// Concrete Umans Code API client.
66pub struct UmansClient {
67    http: ProviderHttpClient,
68}
69
70impl UmansClient {
71    /// Create a client from `UMANS_API_KEY`, falling back to workspace `.env`.
72    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    /// Create a client with an explicit base URL and API key.
83    pub fn new(base_url: &str, api_key: &str) -> Self {
84        UmansClient { http: ProviderHttpClient::new(base_url, api_key) }
85    }
86
87    /// Fetch model metadata from `GET /v1/models/info`.
88    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    /// Build the request body for `POST /v1/messages`.
115    ///
116    /// This is a pure function — no network — making it testable in isolation.
117    ///
118    /// When `tools` is `Some`, the tool schemas are included in the `tools`
119    /// field so the model can issue `tool_use` blocks.
120    ///
121    /// The schema is sent every turn because Umans does not expose reusable-history
122    /// for tool definitions.
123    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    /// Build a Messages request body with the supported Umans reasoning toggle.
130    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    /// Build the HTTP headers map for a Messages API request.
156    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    /// Send a streaming `POST /v1/messages` request and return the HTTP
165    /// response.
166    ///
167    /// When `tools` is `Some`, the compact tool schema is included in the
168    /// request body so the model can issue `tool_use` blocks. The schema is
169    /// sent every turn.
170    ///
171    /// The caller reads lines from the response body and feeds them to
172    /// [`crate::providers::anthropic::parse_sse_chunk`] and
173    /// [`crate::providers::anthropic::parse_sse_event`].
174    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/// Model capabilities.
310#[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/// Reasoning configuration included in live model metadata.
327#[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
339/// Select the completion token budget for a model from live `/v1/models/info`
340/// metadata, falling back to a conservative provider default when metadata is
341/// unavailable.
342pub 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
354/// Parse the public model catalogue shape used by Umans.
355///
356/// The current endpoint returns a map keyed by model id. Supporting the common
357/// `models`/`data` wrappers as well keeps discovery resilient to harmless API
358/// envelope changes and makes the request boundary easy to test without a
359/// network call.
360pub 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
402/// Current Umans Code models from the public docs.
403///
404/// Live metadata can still be fetched with [`UmansClient::fetch_models_info`],
405/// but the picker should remain useful before credentials or network are ready.
406pub 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
415/// Return the safe, model-specific reasoning controls for an Umans model.
416pub 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
440/// Convert a [`ProviderError`] into an [`AgentEvent::Failed`] with a
441/// human-readable message.
442///
443/// HTTP status errors include the status code.
444///
445/// Auth errors (401/403) and rate-limit errors (429) are labeled distinctly.
446pub 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
511/// Try to validate an Umans API key with a lightweight model-info request.
512pub 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}