use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::app::AgentEvent;
use crate::cli::ReasoningEffort;
use crate::providers::{
self, KnownModel, ProviderError, ProviderHttpClient, ProviderMessage, Result, StreamFormat, StreamingProvider,
StreamingRequest,
};
use crate::thndrs_core::auth;
pub const BASE_URL: &str = "https://api.code.umans.ai";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const API_KEY_ENV: &str = "UMANS_API_KEY";
pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ModelInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub base_model: BaseModel,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default)]
pub benchmarks: serde_json::Value,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct BaseModel {
#[serde(default)]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oss_base: Option<String>,
}
pub struct UmansClient {
http: ProviderHttpClient,
}
impl UmansClient {
pub fn from_env_or_dotenv(workspace_root: &Path) -> Result<Self> {
if let Some((api_key, source)) = auth::resolve_credential(API_KEY_ENV, workspace_root) {
tracing::debug!(source = source.label(), "loaded Umans API key");
Ok(Self::new(BASE_URL, &api_key))
} else {
tracing::error!(env = API_KEY_ENV, cwd = %workspace_root.display(), "missing Umans API key");
Err(ProviderError::missing_api_key(API_KEY_ENV))
}
}
pub fn new(base_url: &str, api_key: &str) -> Self {
UmansClient { http: ProviderHttpClient::new(base_url, api_key) }
}
pub fn fetch_models_info(&self) -> Result<HashMap<String, ModelInfo>> {
let url = format!("{}/v1/models/info", self.http.base_url());
let mut resp = self
.http
.agent()
.get(&url)
.header("x-api-key", self.http.api_key())
.header("anthropic-version", ANTHROPIC_VERSION)
.config()
.http_status_as_error(false)
.build()
.call()
.map_err(|e| ProviderError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let body = resp
.body_mut()
.read_to_string()
.map_err(|e| ProviderError::Http(e.to_string()))?;
if !(200..=299).contains(&status) {
return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
}
parse_models_info(&body)
}
pub fn build_messages_request_body(
model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
) -> serde_json::Value {
providers::anthropic::build_messages_request_body(model, messages, max_tokens, stream, tools)
}
pub fn build_messages_request_body_with_reasoning(
model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
effort: ReasoningEffort,
) -> Result<serde_json::Value> {
if !reasoning_options(model).contains(&effort) {
return Err(ProviderError::Json(format!(
"reasoning control `{}` is not supported by {model}",
effort.label()
)));
}
let mut body = Self::build_messages_request_body(model, messages, max_tokens, stream, tools);
match effort {
ReasoningEffort::On => body["thinking"] = serde_json::json!({ "type": "enabled" }),
ReasoningEffort::None => body["thinking"] = serde_json::json!({ "type": "disabled" }),
ReasoningEffort::Auto => {}
other => {
return Err(ProviderError::Json(format!(
"reasoning control `{}` cannot be lowered for Umans",
other.label()
)));
}
}
Ok(body)
}
pub fn build_headers(&self) -> Vec<(String, String)> {
vec![
("x-api-key".to_string(), self.http.api_key().to_string()),
("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string()),
("Content-Type".to_string(), "application/json".to_string()),
]
}
pub fn send_streaming_request(
&self, model: &str, messages: &[ProviderMessage], max_tokens: u32, tools: Option<&serde_json::Value>,
effort: ReasoningEffort,
) -> Result<ureq::http::Response<ureq::Body>> {
let url = format!("{}/v1/messages", self.http.base_url());
let body = Self::build_messages_request_body_with_reasoning(model, messages, max_tokens, true, tools, effort)?;
let tool_count = tools.and_then(|t| t.as_array()).map_or(0, Vec::len);
tracing::info!(
model,
max_tokens,
messages = messages.len(),
tools = tool_count,
"sending Umans streaming request"
);
let mut request = self.http.agent().post(&url);
for (key, value) in self.build_headers() {
request = request.header(&key, &value);
}
let mut response = request
.config()
.http_status_as_error(false)
.build()
.send_json(&body)
.map_err(|e| {
tracing::error!(error = %e, "Umans request failed before HTTP response");
ProviderError::Http(e.to_string())
})?;
let status = response.status().as_u16();
if !(200..=299).contains(&status) {
let body = response
.body_mut()
.read_to_string()
.unwrap_or_else(|e| format!("failed to read error body: {e}"));
let body = providers::summarize_error_body(&body);
tracing::error!(status, error = %body, "Umans request returned non-success status");
return Err(ProviderError::Status { code: status, body });
}
tracing::info!(status, "Umans streaming request connected");
Ok(response)
}
}
impl StreamingProvider for UmansClient {
type Metadata = HashMap<String, ModelInfo>;
fn name(&self) -> &'static str {
"Umans"
}
fn load_status(&self) -> String {
String::from("provider: loading UMANS_API_KEY")
}
fn request_status(&self, model: &str) -> String {
format!("provider: POST /v1/messages model={model}")
}
fn from_env_or_dotenv(root: &Path) -> Result<Self> {
UmansClient::from_env_or_dotenv(root)
}
fn load_metadata(&self) -> Result<Self::Metadata> {
self.fetch_models_info()
}
fn metadata_loaded_event(&self, metadata: &Self::Metadata) -> Option<AgentEvent> {
let mut items = model_picker_items(metadata);
items.extend(
providers::opencode::known_models()
.into_iter()
.map(|model| (model.id.to_string(), model.description.to_string())),
);
items.extend(
providers::chatgpt_codex::known_models()
.into_iter()
.map(|model| (model.id.to_string(), model.description.to_string())),
);
Some(AgentEvent::ModelMetadataLoaded(items))
}
fn metadata_status(&self, model: &str, metadata: &Self::Metadata) -> Option<String> {
model_status(model, metadata)
}
fn token_budget(&self, model: &str, metadata: Option<&Self::Metadata>) -> u32 {
recommended_max_tokens_for_model(model, metadata)
}
fn serialized_request_body(
&self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
) -> Result<Vec<u8>> {
let body = UmansClient::build_messages_request_body_with_reasoning(
model,
messages,
request.max_tokens,
true,
Some(request.tools),
request.reasoning_effort,
)?;
providers::serialize_request_body(&body)
}
fn send_streaming_request(
&self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
) -> Result<ureq::http::Response<ureq::Body>> {
UmansClient::send_streaming_request(
self,
model,
messages,
request.max_tokens,
Some(request.tools),
request.reasoning_effort,
)
}
fn stream_format(&self, _model: &str) -> Result<StreamFormat> {
Ok(StreamFormat::AnthropicMessages)
}
fn request_error_message(error: &ProviderError) -> String {
match error_to_agent_event(error) {
AgentEvent::Failed(msg) => msg,
_ => error.to_string(),
}
}
fn is_retryable_request_error(error: &ProviderError) -> bool {
is_retryable_error(error)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Capabilities {
#[serde(default)]
pub max_completion_tokens: u64,
#[serde(default)]
pub recommended_max_tokens: u64,
#[serde(default)]
pub context_window: u64,
#[serde(default)]
pub supports_vision: serde_json::Value,
#[serde(default)]
pub supports_tools: bool,
#[serde(default)]
pub reasoning: Reasoning,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Reasoning {
#[serde(default)]
pub supported: bool,
#[serde(default)]
pub can_disable: bool,
#[serde(default)]
pub levels: Vec<String>,
#[serde(default)]
pub default_level: Option<String>,
}
pub fn recommended_max_tokens_for_model(model: &str, models: Option<&HashMap<String, ModelInfo>>) -> u32 {
models
.and_then(|models| models.get(model))
.and_then(|info| u32::try_from(info.capabilities.recommended_max_tokens).ok())
.filter(|tokens| *tokens > 0)
.unwrap_or(DEFAULT_RECOMMENDED_MAX_TOKENS)
}
pub fn is_retryable_error(err: &ProviderError) -> bool {
err.is_retryable()
}
pub fn parse_models_info(body: &str) -> Result<HashMap<String, ModelInfo>> {
let value: serde_json::Value = serde_json::from_str(body).map_err(|e| ProviderError::Json(e.to_string()))?;
let catalogue = value.get("models").or_else(|| value.get("data")).unwrap_or(&value);
let mut models = match catalogue {
serde_json::Value::Object(entries) => entries
.iter()
.filter_map(|(id, value)| {
serde_json::from_value::<ModelInfo>(value.clone())
.ok()
.map(|info| (id.clone(), info))
})
.collect::<HashMap<_, _>>(),
serde_json::Value::Array(entries) => entries
.iter()
.filter_map(|value| {
let info = serde_json::from_value::<ModelInfo>(value.clone()).ok()?;
let id = if info.name.trim().is_empty() { info.base_model.name.clone() } else { info.name.clone() };
(!id.trim().is_empty()).then_some((id, info))
})
.collect::<HashMap<_, _>>(),
_ => {
return Err(ProviderError::Json(
"model catalogue must be an object or array".to_string(),
));
}
};
for (id, info) in &mut models {
if info.name.trim().is_empty() {
info.name.clone_from(id);
}
if info.display_name.trim().is_empty() {
info.display_name.clone_from(&info.name);
}
if info.base_model.name.trim().is_empty() {
info.base_model.name.clone_from(&info.name);
}
}
Ok(models)
}
pub fn known_models() -> Vec<KnownModel> {
vec![
KnownModel { id: "umans-coder", description: "Recommended route, currently Kimi K2.7-Code" },
KnownModel { id: "umans-kimi-k2.7", description: "Hard coding tasks, always-on reasoning" },
KnownModel { id: "umans-glm-5.2", description: "Latest GLM, largest context window" },
KnownModel { id: "umans-flash", description: "Fast light model for context and summaries" },
]
}
pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
match model {
"umans-glm-5.2" | "umans-glm-5.1" | "umans-flash" | "umans-qwen3.6-35b-a3b" => {
vec![ReasoningEffort::Auto, ReasoningEffort::On, ReasoningEffort::None]
}
_ => vec![ReasoningEffort::Auto],
}
}
pub fn model_picker_items(models: &HashMap<String, ModelInfo>) -> Vec<(String, String)> {
let mut items: Vec<(String, String)> = models
.iter()
.map(|(id, info)| (id.clone(), model_picker_detail(info)))
.collect();
items.sort_by(|left, right| left.0.cmp(&right.0));
items
}
pub fn model_status(model: &str, models: &HashMap<String, ModelInfo>) -> Option<String> {
models
.get(model)
.map(|info| format!("model: {} {}", info.display_name, model_picker_detail(info)))
}
pub fn error_to_agent_event(err: &ProviderError) -> AgentEvent {
AgentEvent::Failed(actionable_error_message(err))
}
fn actionable_error_message(err: &ProviderError) -> String {
match err {
ProviderError::MissingApiKey { .. } => err.to_string(),
ProviderError::Status { code, body } => match code {
401 | 403 => {
format!("Umans authentication failed (HTTP {code}); check UMANS_API_KEY or run `thndrs login umans`")
}
429 => "Umans rate limit exceeded; check your Umans usage/plan, then retry".to_string(),
500..=599 => format!("Umans server error (HTTP {code}): {body}; retry shortly"),
_ => format!("Umans request failed (HTTP {code}): {body}"),
},
ProviderError::Auth(message) => {
format!("Umans authentication failed: {message}; check UMANS_API_KEY or run `thndrs login umans`")
}
ProviderError::AuthUnavailable(message) => {
format!("Umans authentication verification unavailable: {message}; retry shortly")
}
ProviderError::Http(message) => {
format!("Umans network error: {message}; check your connection and retry")
}
ProviderError::Json(message) => {
format!("Umans response parse error: {message}; retry or check the Umans service status")
}
ProviderError::InvalidModelId { model, .. } => {
format!("Umans model `{model}` is not valid; choose a current Umans model with `/model`")
}
}
}
fn model_picker_detail(info: &ModelInfo) -> String {
let provider = info
.base_model
.provider
.as_deref()
.or(info.base_model.family.as_deref())
.unwrap_or(info.base_model.name.as_str());
let tools = if info.capabilities.supports_tools { "tools" } else { "no tools" };
let reasoning = if info.capabilities.reasoning.supported { "reasoning" } else { "no reasoning" };
format!(
"{} · ctx {} · out {} · {} · {}",
provider,
compact_token_count(info.capabilities.context_window),
compact_token_count(info.capabilities.recommended_max_tokens),
tools,
reasoning
)
}
fn compact_token_count(tokens: u64) -> String {
const K: u64 = 1024;
const M: u64 = K * K;
if tokens >= M && tokens.is_multiple_of(M) {
format!("{}M", tokens / M)
} else if tokens >= K && tokens.is_multiple_of(K) {
format!("{}k", tokens / K)
} else {
tokens.to_string()
}
}
pub fn validate_api_key(api_key: &str) -> std::result::Result<(), String> {
probe_api_key(api_key).map_err(|error| format!("validation failed: {error}"))
}
pub fn probe_api_key(api_key: &str) -> Result<()> {
validate_api_key_at(BASE_URL, api_key)
}
fn validate_api_key_at(base_url: &str, api_key: &str) -> Result<()> {
UmansClient::new(base_url, api_key).fetch_models_info().map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::anthropic::{SseEvent, parse_sse_chunk, parse_sse_event, sse_to_agent_event};
use crate::providers::{ProviderContentBlock, ProviderMessage, ProviderMessageContent};
use std::env;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn mock_models_info_server(body: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = [0_u8; 1024];
let count = stream.read(&mut request).expect("read request");
let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase();
assert!(request.contains("x-api-key: sk-validation"));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).expect("write response");
});
format!("http://{addr}")
}
fn mock_models_info_response_server(status: &'static str, body: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).expect("write response");
});
format!("http://{addr}")
}
#[test]
fn build_messages_request_body_has_required_fields() {
let messages = vec![ProviderMessage::user("Hello!")];
let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, None);
assert_eq!(body["model"], "umans-coder");
assert_eq!(body["max_tokens"], 4096);
assert_eq!(body["stream"], true);
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"], "Hello!");
assert!(body.get("tools").is_none(), "no tools when None passed");
}
#[test]
fn build_messages_request_body_non_stream() {
let messages = vec![ProviderMessage::user("test")];
let body = UmansClient::build_messages_request_body("umans-glm-5.2", &messages, 8192, false, None);
assert_eq!(body["model"], "umans-glm-5.2");
assert_eq!(body["stream"], false);
}
#[test]
fn build_messages_request_body_includes_tools() {
let messages = vec![ProviderMessage::user("find files")];
let defs = crate::tools::tool_definitions();
let catalog = crate::tools::tool_catalog_schemas(&defs);
let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, Some(&catalog));
let request_names: Vec<&str> = body["tools"]
.as_array()
.expect("tools should be present")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect();
let definition_names: Vec<&str> = defs.iter().map(|definition| definition.name.as_ref()).collect();
assert_eq!(request_names, definition_names);
}
#[test]
fn build_messages_request_body_omits_empty_tools() {
let messages = vec![ProviderMessage::user("hi")];
let empty_tools = serde_json::json!([]);
let body = UmansClient::build_messages_request_body("umans-coder", &messages, 4096, true, Some(&empty_tools));
assert!(body.get("tools").is_none(), "empty tool array should be omitted");
}
#[test]
fn recommended_max_tokens_prefers_live_metadata_with_default_fallback() {
let json = include_str!("./fixtures/model_info.json");
let mut models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
let mut high_budget = models["umans-coder"].clone();
high_budget.capabilities.recommended_max_tokens = 131_071;
models.insert("metadata-high-budget".to_string(), high_budget);
assert_eq!(recommended_max_tokens_for_model("umans-coder", Some(&models)), 32_768);
assert_eq!(
recommended_max_tokens_for_model("metadata-high-budget", Some(&models)),
131_071
);
assert_eq!(
recommended_max_tokens_for_model("unknown-model", Some(&models)),
DEFAULT_RECOMMENDED_MAX_TOKENS
);
assert_eq!(
recommended_max_tokens_for_model("umans-glm-5.2", None),
DEFAULT_RECOMMENDED_MAX_TOKENS
);
}
#[test]
fn model_metadata_formats_picker_items_and_status() {
let json = include_str!("./fixtures/model_info.json");
let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
let items = model_picker_items(&models);
let coder = items
.iter()
.find(|(id, _)| id == "umans-coder")
.expect("umans-coder item");
assert!(coder.1.contains("ctx 256k"));
assert!(coder.1.contains("out 32k"));
assert!(coder.1.contains("tools"));
let status = model_status("umans-coder", &models).expect("model status");
assert!(status.starts_with("model: "));
assert!(status.contains("ctx 256k"));
assert!(model_status("unknown-model", &models).is_none());
}
#[test]
fn retryable_error_classification_matches_policy() {
assert!(!is_retryable_error(&ProviderError::missing_api_key(API_KEY_ENV)));
assert!(!is_retryable_error(&ProviderError::Status {
code: 400,
body: "bad request".into()
}));
assert!(!is_retryable_error(&ProviderError::Status {
code: 401,
body: "unauthorized".into()
}));
assert!(is_retryable_error(&ProviderError::Status {
code: 429,
body: "too many requests".into()
}));
assert!(is_retryable_error(&ProviderError::Status {
code: 503,
body: "service unavailable".into()
}));
assert!(is_retryable_error(&ProviderError::Http("connection reset".into())));
assert!(!is_retryable_error(&ProviderError::Http("request aborted".into())));
}
#[test]
fn build_headers_include_api_key_and_version() {
let client = UmansClient::new(BASE_URL, "sk-test-key");
let headers = client.build_headers();
let header_map: HashMap<String, String> = headers.into_iter().collect();
assert_eq!(header_map.get("x-api-key").unwrap(), "sk-test-key");
assert_eq!(header_map.get("anthropic-version").unwrap(), ANTHROPIC_VERSION);
assert_eq!(header_map.get("Content-Type").unwrap(), "application/json");
assert!(
!header_map
.keys()
.any(|key| key.eq_ignore_ascii_case("x-umans-websearch-provider"))
);
}
#[test]
fn from_env_or_dotenv_missing_key_returns_error() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
let result = UmansClient::from_env_or_dotenv(dir.path());
assert!(matches!(result, Err(ProviderError::MissingApiKey { env }) if env == API_KEY_ENV));
}
#[test]
fn from_env_or_dotenv_reads_workspace_env_file() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "UMANS_API_KEY=sk-dotenv-key\n").unwrap();
let client = UmansClient::from_env_or_dotenv(dir.path()).unwrap();
let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-dotenv-key");
}
#[test]
fn from_env_or_dotenv_reads_exported_quoted_env_file_value() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"export UMANS_API_KEY=\"sk-quoted-dotenv-key\"\n",
)
.unwrap();
let client = UmansClient::from_env_or_dotenv(dir.path()).unwrap();
let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-quoted-dotenv-key");
}
#[test]
fn from_env_or_dotenv_reads_global_credential_store() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(home.join(".thndrs")).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(
home.join(".thndrs").join("credentials.env"),
"UMANS_API_KEY=sk-global-key\n",
)
.unwrap();
let old_home = env::var_os("HOME");
unsafe { env::set_var("HOME", &home) };
let client = UmansClient::from_env_or_dotenv(&workspace).unwrap();
unsafe {
if let Some(home) = old_home {
env::set_var("HOME", home);
} else {
env::remove_var("HOME");
}
}
let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-global-key");
}
#[test]
fn from_env_or_dotenv_reads_project_credential_store() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(workspace.join(".thndrs")).unwrap();
std::fs::write(
workspace.join(".thndrs").join("credentials.env"),
"UMANS_API_KEY=sk-project-key\n",
)
.unwrap();
let old_home = env::var_os("HOME");
unsafe { env::set_var("HOME", &home) };
let client = UmansClient::from_env_or_dotenv(&workspace).unwrap();
unsafe {
if let Some(home) = old_home {
env::set_var("HOME", home);
} else {
env::remove_var("HOME");
}
}
let headers: HashMap<String, String> = client.build_headers().into_iter().collect();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-project-key");
}
#[test]
fn missing_key_error_includes_setup_hint() {
let _guard = crate::test_env::lock();
unsafe {
env::remove_var(API_KEY_ENV);
}
let dir = tempfile::tempdir().unwrap();
let result = match UmansClient::from_env_or_dotenv(dir.path()) {
Ok(_) => panic!("missing key should fail"),
Err(err) => err,
};
let message = result.to_string();
assert!(message.contains("thndrs setup"));
assert!(message.contains("thndrs login"));
assert!(!message.contains("sk-"));
}
#[test]
fn validation_does_not_persist_provider_payloads() {
let _guard = crate::test_env::lock();
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
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}}}}"#;
let base_url = mock_models_info_server(body);
let old_home = env::var_os("HOME");
unsafe { env::set_var("HOME", &home) };
validate_api_key_at(&base_url, "sk-validation").expect("validation succeeds");
unsafe {
if let Some(home) = old_home {
env::set_var("HOME", home);
} else {
env::remove_var("HOME");
}
}
assert!(!home.join(".thndrs").join("credentials.env").exists());
assert!(!workspace.join(".thndrs").join("credentials.env").exists());
}
#[test]
fn validation_preserves_rejected_credential_status() {
let base_url = mock_models_info_response_server("401 Unauthorized", r#"{"error":"invalid key"}"#);
let error = validate_api_key_at(&base_url, "rejected-key").expect_err("credential should be rejected");
assert!(matches!(error, ProviderError::Status { code: 401, .. }));
}
#[test]
fn parse_models_info_fixture() {
let json = r#"{
"umans-coder": {
"name": "umans-coder",
"display_name": "Umans Coder",
"description": "Recommended model.",
"base_model": {"name": "kimi-k2.7-code", "provider": "Moonshot", "oss_base": "Kimi K2.7-Code"},
"capabilities": {
"max_completion_tokens": 262144,
"recommended_max_tokens": 32768,
"context_window": 262144,
"supports_vision": true,
"supports_tools": true,
"reasoning": {"supported": true, "can_disable": false, "levels": [], "default_level": null}
},
"benchmarks": {}
},
"umans-glm-5.2": {
"name": "umans-glm-5.2",
"display_name": "Umans GLM 5.2",
"description": "Latest GLM.",
"base_model": {"name": "GLM-5.2", "family": "GLM"},
"capabilities": {
"max_completion_tokens": 131072,
"recommended_max_tokens": 131071,
"context_window": 405504,
"supports_vision": "via-handoff",
"supports_tools": true,
"reasoning": {"supported": true, "can_disable": true, "levels": ["none", "high", "max"], "default_level": "high"}
},
"benchmarks": {}
}
}"#;
let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
let coder = models.get("umans-coder").expect("umans-coder present");
assert_eq!(coder.display_name, "Umans Coder");
assert_eq!(coder.capabilities.context_window, 262144);
assert_eq!(coder.capabilities.recommended_max_tokens, 32768);
assert!(coder.capabilities.supports_tools);
assert!(coder.capabilities.reasoning.supported);
assert!(!coder.capabilities.reasoning.can_disable);
let glm = models.get("umans-glm-5.2").expect("umans-glm-5.2 present");
assert_eq!(glm.capabilities.context_window, 405504);
assert!(glm.capabilities.reasoning.can_disable);
assert_eq!(glm.capabilities.reasoning.default_level.as_deref(), Some("high"));
assert_eq!(glm.capabilities.reasoning.levels, vec!["none", "high", "max"]);
}
#[test]
fn parse_models_info_from_live_fixture() {
let json = include_str!("./fixtures/model_info.json");
let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
let coder = &models["umans-coder"];
assert_eq!(coder.capabilities.context_window, 262144);
}
#[test]
fn parse_models_info_accepts_catalogue_wrappers_and_missing_display_fields() {
let body = r#"{
"data": [
{"name":"umans-new-model","base_model":{"name":"new-base"},"capabilities":{"supports_tools":true}}
]
}"#;
let models = parse_models_info(body).expect("wrapped catalogue");
let model = models.get("umans-new-model").expect("model id");
assert_eq!(model.display_name, "umans-new-model");
assert_eq!(model.base_model.name, "new-base");
assert!(model.capabilities.supports_tools);
assert_eq!(model.capabilities.recommended_max_tokens, 0);
}
#[test]
fn parse_sse_event_text_delta() {
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event = parse_sse_event("content_block_delta", data);
assert_eq!(event, SseEvent::TextDelta("Hello".to_string()));
}
#[test]
fn parse_sse_event_text_delta_strips_think_tags() {
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"</think>\nDone"}}"#;
let event = parse_sse_event("content_block_delta", data);
assert_eq!(event, SseEvent::TextDelta("\nDone".to_string()));
}
#[test]
fn parse_sse_event_thinking_delta() {
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think..."}}"#;
let event = parse_sse_event("content_block_delta", data);
assert_eq!(event, SseEvent::ThinkingDelta("Let me think...".to_string()));
}
#[test]
fn parse_sse_event_message_start() {
let event = parse_sse_event("message_start", "{}");
assert_eq!(event, SseEvent::MessageStart);
}
#[test]
fn parse_sse_event_content_block_start_thinking() {
let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#;
let event = parse_sse_event("content_block_start", data);
assert_eq!(event, SseEvent::ThinkingStart);
}
#[test]
fn parse_sse_event_content_block_start_text() {
let data = r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
let event = parse_sse_event("content_block_start", data);
assert_eq!(event, SseEvent::TextStart);
}
#[test]
fn parse_sse_event_message_stop() {
let event = parse_sse_event("message_stop", "{}");
assert_eq!(event, SseEvent::MessageStop);
}
#[test]
fn parse_sse_event_input_json_delta() {
let data = serde_json::json!({
"type": "content_block_delta",
"index": 1,
"delta": {
"type": "input_json_delta",
"partial_json": "{\"pattern\""
}
})
.to_string();
let event = parse_sse_event("content_block_delta", &data);
assert_eq!(
event,
SseEvent::InputJsonDelta { index: 1, partial_json: "{\"pattern\"".to_string() }
);
}
#[test]
fn parse_sse_event_content_block_stop_with_index() {
let data = r#"{"type":"content_block_stop","index":2}"#;
let event = parse_sse_event("content_block_stop", data);
assert_eq!(event, SseEvent::ContentBlockStop { index: Some(2) });
}
#[test]
fn parse_sse_event_error() {
let data = r#"{"type":"error","error":{"type":"overloaded_error","message":"Server overloaded"}}"#;
let event = parse_sse_event("error", data);
assert_eq!(event, SseEvent::Error("Server overloaded".to_string()));
}
#[test]
fn parse_sse_event_message_delta_with_stop_reason() {
let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#;
let event = parse_sse_event("message_delta", data);
assert_eq!(
event,
SseEvent::MessageDelta { stop_reason: Some("end_turn".to_string()) }
);
}
#[test]
fn sse_to_agent_text_delta() {
let event = SseEvent::TextDelta("response".to_string());
assert_eq!(
sse_to_agent_event(&event),
Some(AgentEvent::AssistantDelta("response".to_string()))
);
}
#[test]
fn sse_to_agent_thinking_delta() {
let event = SseEvent::ThinkingDelta("reasoning".to_string());
assert_eq!(
sse_to_agent_event(&event),
Some(AgentEvent::ReasoningDelta("reasoning".to_string()))
);
}
#[test]
fn sse_to_agent_message_start() {
assert_eq!(sse_to_agent_event(&SseEvent::MessageStart), Some(AgentEvent::Started));
}
#[test]
fn sse_to_agent_message_stop() {
assert_eq!(sse_to_agent_event(&SseEvent::MessageStop), None);
}
#[test]
fn sse_to_agent_error() {
assert_eq!(
sse_to_agent_event(&SseEvent::Error("fail".to_string())),
Some(AgentEvent::Failed("fail".to_string()))
);
}
#[test]
fn sse_to_agent_content_block_start_no_event() {
assert_eq!(sse_to_agent_event(&SseEvent::TextStart), None);
assert_eq!(sse_to_agent_event(&SseEvent::ThinkingStart), None);
}
#[test]
fn parse_sse_chunk_multiple_events() {
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";
let events = parse_sse_chunk(chunk);
assert_eq!(events.len(), 4);
assert_eq!(events[0].0, "message_start");
assert_eq!(events[1].0, "content_block_start");
assert_eq!(events[2].0, "content_block_delta");
assert_eq!(events[3].0, "message_stop");
}
#[test]
fn parse_sse_chunk_empty() {
let events = parse_sse_chunk("");
assert!(events.is_empty());
}
#[test]
fn parse_sse_chunk_no_trailing_newline() {
let chunk = "event: message_stop\ndata: {}";
let events = parse_sse_chunk(chunk);
assert_eq!(events.len(), 1);
assert_eq!(events[0].0, "message_stop");
}
#[test]
fn parse_full_stream_fixture_into_agent_events() {
let sse = include_str!("./fixtures/stream_response.sse");
let chunks = parse_sse_chunk(sse);
let agent_events: Vec<AgentEvent> = chunks
.iter()
.filter_map(|(event_type, data)| {
let sse_event = parse_sse_event(event_type, data);
sse_to_agent_event(&sse_event)
})
.collect();
assert_eq!(agent_events.len(), 3);
assert_eq!(agent_events[0], AgentEvent::Started);
assert_eq!(agent_events[1], AgentEvent::ReasoningDelta("Analyzing...".to_string()));
assert_eq!(agent_events[2], AgentEvent::AssistantDelta("Hello world".to_string()));
}
#[test]
fn error_to_agent_event_missing_api_key() {
let event = error_to_agent_event(&ProviderError::missing_api_key(API_KEY_ENV));
assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("UMANS_API_KEY")));
}
#[test]
fn error_to_agent_event_auth_failure() {
let event = error_to_agent_event(&ProviderError::Status { code: 401, body: "Unauthorized".into() });
assert!(
matches!(event, AgentEvent::Failed(ref msg) if msg.contains("authentication failed") && msg.contains("thndrs login umans"))
);
assert!(!format!("{event:?}").contains("Unauthorized"));
}
#[test]
fn error_to_agent_event_rate_limit() {
let event = error_to_agent_event(&ProviderError::Status { code: 429, body: "Too Many Requests".into() });
assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("rate limit") && msg.contains("usage/plan")));
}
#[test]
fn error_to_agent_event_server_error() {
let event = error_to_agent_event(&ProviderError::Status { code: 500, body: "Internal Server Error".into() });
assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("server error")));
}
#[test]
fn error_to_agent_event_network_error() {
let event = error_to_agent_event(&ProviderError::Http("connection refused".into()));
assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("network error")));
}
#[test]
fn error_to_agent_event_json_error() {
let event = error_to_agent_event(&ProviderError::Json("bad metadata".into()));
assert!(matches!(event, AgentEvent::Failed(msg) if msg.contains("response parse error")));
}
#[test]
fn no_network_request_construction() {
let messages = vec![ProviderMessage::user("test"), ProviderMessage::assistant("response")];
let body = UmansClient::build_messages_request_body("umans-coder", &messages, 8192, true, None);
assert_eq!(body["model"], "umans-coder");
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
assert_eq!(body["stream"], true);
}
#[test]
fn no_network_stream_parsing_from_fixture() {
let sse = include_str!("./fixtures/stream_response.sse");
let chunks = parse_sse_chunk(sse);
assert!(!chunks.is_empty());
let events: Vec<SseEvent> = chunks.iter().map(|(et, data)| parse_sse_event(et, data)).collect();
assert!(events.iter().any(|e| matches!(e, SseEvent::ThinkingDelta(_))));
assert!(events.iter().any(|e| matches!(e, SseEvent::TextDelta(_))));
assert!(events.contains(&SseEvent::MessageStart));
assert!(events.contains(&SseEvent::MessageStop));
}
#[test]
fn no_network_metadata_parsing_from_fixture() {
let json = include_str!("./fixtures/model_info.json");
let models: HashMap<String, ModelInfo> = serde_json::from_str(json).expect("parse");
assert!(models.contains_key("umans-coder"));
let coder = &models["umans-coder"];
assert!(coder.capabilities.supports_tools);
assert!(!coder.capabilities.reasoning.can_disable);
}
#[test]
#[ignore = "requires UMANS_API_KEY and network access"]
fn live_models_info() {
let workspace_root = env::current_dir().expect("current dir");
let client = UmansClient::from_env_or_dotenv(&workspace_root).expect("UMANS_API_KEY must be set");
let models = client.fetch_models_info().expect("fetch models info");
assert!(models.contains_key("umans-coder"));
}
#[test]
fn message_user_constructor() {
let msg = ProviderMessage::user("test prompt");
assert_eq!(msg.role, "user");
assert_eq!(msg.as_text(), "test prompt");
}
#[test]
fn message_assistant_constructor() {
let msg = ProviderMessage::assistant("response");
assert_eq!(msg.role, "assistant");
assert_eq!(msg.as_text(), "response");
}
#[test]
fn tool_result_message_has_correct_shape() {
let msg = ProviderMessage::tool_result("toolu_01", "found 2 files", false);
assert_eq!(msg.role, "user");
match &msg.content {
ProviderMessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
match &blocks[0] {
ProviderContentBlock::ToolResult { tool_use_id, content, is_error } => {
assert_eq!(tool_use_id, "toolu_01");
assert_eq!(content, "found 2 files");
assert_eq!(*is_error, Some(false));
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
other => panic!("expected Blocks, got {other:?}"),
}
}
#[test]
fn assistant_blocks_message_serializes_tool_use() {
let blocks = vec![ProviderContentBlock::ToolUse {
id: "toolu_01".to_string(),
name: "find_files".to_string(),
input: serde_json::json!({"pattern": "Cargo"}),
}];
let msg = ProviderMessage::assistant_blocks(blocks);
let json = serde_json::to_value(&msg).expect("serialize");
assert_eq!(json["role"], "assistant");
let content = &json["content"];
assert!(content.is_array(), "content should be an array for blocks");
assert_eq!(content[0]["type"], "tool_use");
assert_eq!(content[0]["id"], "toolu_01");
assert_eq!(content[0]["name"], "find_files");
assert_eq!(content[0]["input"]["pattern"], "Cargo");
}
#[test]
fn text_message_content_serializes_as_string() {
let msg = ProviderMessage::user("hello");
let json = serde_json::to_value(&msg).expect("serialize");
assert_eq!(json["content"], "hello");
assert!(json["content"].is_string(), "text content should serialize as a string");
}
#[test]
fn tool_result_block_serializes_with_is_error() {
let msg = ProviderMessage::tool_result("toolu_02", "command failed", true);
let json = serde_json::to_value(&msg).expect("serialize");
let block = &json["content"][0];
assert_eq!(block["type"], "tool_result");
assert_eq!(block["tool_use_id"], "toolu_02");
assert_eq!(block["content"], "command failed");
assert_eq!(block["is_error"], true);
}
#[test]
fn user_image_block_serializes_as_anthropic_content() {
let msg = ProviderMessage::user_blocks(vec![ProviderContentBlock::Image {
source: crate::providers::ProviderImageSource::Base64 {
media_type: "image/png".to_string(),
data: "aGVsbG8=".to_string(),
},
}]);
let json = serde_json::to_value(&msg).expect("serialize");
let block = &json["content"][0];
assert_eq!(block["type"], "image");
assert_eq!(block["source"]["type"], "base64");
assert_eq!(block["source"]["media_type"], "image/png");
assert_eq!(block["source"]["data"], "aGVsbG8=");
}
#[test]
fn assistant_blocks_with_text_and_tool_use_serializes_in_order() {
let blocks = vec![
ProviderContentBlock::Text { text: "Let me search.".to_string() },
ProviderContentBlock::ToolUse {
id: "toolu_03".to_string(),
name: "search_text".to_string(),
input: serde_json::json!({"pattern": "fn main"}),
},
];
let msg = ProviderMessage::assistant_blocks(blocks);
let json = serde_json::to_value(&msg).expect("serialize");
assert_eq!(json["content"][0]["type"], "text");
assert_eq!(json["content"][0]["text"], "Let me search.");
assert_eq!(json["content"][1]["type"], "tool_use");
assert_eq!(json["content"][1]["id"], "toolu_03");
}
#[test]
fn message_content_round_trips_through_json() {
let original = ProviderMessage::tool_result("toolu_99", "result text", false);
let json = serde_json::to_string(&original).expect("serialize");
let parsed: ProviderMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, parsed);
}
#[test]
fn reasoning_toggle_is_lowered_only_for_models_that_can_disable() {
let messages = vec![ProviderMessage::user("hello")];
let enabled = UmansClient::build_messages_request_body_with_reasoning(
"umans-flash",
&messages,
128,
true,
None,
ReasoningEffort::On,
)
.expect("enabled body");
assert_eq!(enabled["thinking"]["type"], "enabled");
let disabled = UmansClient::build_messages_request_body_with_reasoning(
"umans-glm-5.2",
&messages,
128,
true,
None,
ReasoningEffort::None,
)
.expect("disabled body");
assert_eq!(disabled["thinking"]["type"], "disabled");
assert!(
UmansClient::build_messages_request_body_with_reasoning(
"umans-coder",
&messages,
128,
true,
None,
ReasoningEffort::None,
)
.is_err()
);
}
}