sparrow/config/
validate.rs1use crate::config::Config;
2
3pub fn validate_config(config: &Config) -> Vec<ConfigIssue> {
5 let mut issues = Vec::new();
6
7 if config.budget.daily_usd <= 0.0 {
9 issues.push(ConfigIssue::warn(
10 "budget.daily_usd is 0 — no cloud providers will be used",
11 ));
12 }
13 if config.budget.session_usd <= 0.0 {
14 issues.push(ConfigIssue::warn(
15 "budget.session_usd is 0 — runs will stop immediately",
16 ));
17 }
18 if config.budget.daily_usd > 100.0 {
19 issues.push(ConfigIssue::warn(
20 "budget.daily_usd is very high ($100+). Consider setting a reasonable cap",
21 ));
22 }
23
24 if config.providers.is_empty() {
26 issues.push(ConfigIssue::info("No providers configured. Add one with 'sparrow auth add' or set *_API_KEY env vars. Ollama will be tried as fallback."));
27 }
28 for (name, pconfig) in &config.providers {
29 if pconfig.models.is_empty() {
30 issues.push(ConfigIssue::warn(&format!(
31 "Provider '{}' has no models configured",
32 name
33 )));
34 }
35 if pconfig.adapter.is_empty() {
36 issues.push(ConfigIssue::error(&format!(
37 "Provider '{}' has no adapter set",
38 name
39 )));
40 }
41 }
42
43 if config.routing.free_first && config.providers.is_empty() {
45 issues.push(ConfigIssue::info(
46 "routing.free_first is enabled but no free providers configured. Ollama will be tried.",
47 ));
48 }
49
50 if config.defaults.sandbox == "local-hardened" && !cfg!(target_os = "linux") {
52 issues.push(ConfigIssue::warn(
53 "sandbox 'local-hardened' requires Linux. Falling back to 'local'.",
54 ));
55 }
56
57 if !config.skills.dir.exists() {
59 issues.push(ConfigIssue::info(&format!(
60 "Skills directory does not exist: {}. It will be created automatically.",
61 config.skills.dir.display()
62 )));
63 }
64
65 issues
66}
67
68#[derive(Debug, Clone)]
69pub enum IssueLevel {
70 Info,
71 Warning,
72 Error,
73}
74
75#[derive(Debug, Clone)]
76pub struct ConfigIssue {
77 pub level: IssueLevel,
78 pub message: String,
79}
80
81impl ConfigIssue {
82 pub fn info(msg: &str) -> Self {
83 Self {
84 level: IssueLevel::Info,
85 message: msg.into(),
86 }
87 }
88 pub fn warn(msg: &str) -> Self {
89 Self {
90 level: IssueLevel::Warning,
91 message: msg.into(),
92 }
93 }
94 pub fn error(msg: &str) -> Self {
95 Self {
96 level: IssueLevel::Error,
97 message: msg.into(),
98 }
99 }
100
101 pub fn icon(&self) -> &str {
102 match self.level {
103 IssueLevel::Info => "ℹ",
104 IssueLevel::Warning => "⚠",
105 IssueLevel::Error => "✗",
106 }
107 }
108}
109
110pub async fn ping_provider(
112 name: &str,
113 base_url: &str,
114 api_key: &str,
115 adapter: &str,
116) -> ProviderHealth {
117 let url = match adapter {
118 "ollama" => format!("{}/api/tags", base_url.trim_end_matches('/')),
119 "openai-compatible" => format!("{}/models", base_url.trim_end_matches('/')),
120 "anthropic-messages" => format!("{}/v1/messages", base_url.trim_end_matches('/')),
121 _ => format!("{}/models", base_url.trim_end_matches('/')),
122 };
123
124 let client = reqwest::Client::builder()
125 .timeout(std::time::Duration::from_secs(5))
126 .build()
127 .unwrap_or_default();
128
129 let mut req = client.get(&url);
130 if adapter != "ollama" && !api_key.is_empty() {
131 req = req.header("Authorization", format!("Bearer {}", api_key));
132 }
133
134 match req.send().await {
135 Ok(resp) => {
136 let status = resp.status().as_u16();
137 ProviderHealth {
138 name: name.into(),
139 reachable: status < 500,
140 status_code: status,
141 latency_ms: 0,
142 message: if status == 200 {
143 "OK".into()
144 } else {
145 format!("HTTP {}", status)
146 },
147 }
148 }
149 Err(e) => ProviderHealth {
150 name: name.into(),
151 reachable: false,
152 status_code: 0,
153 latency_ms: 0,
154 message: format!("{}", e),
155 },
156 }
157}
158
159#[derive(Debug, Clone)]
160pub struct ProviderHealth {
161 pub name: String,
162 pub reachable: bool,
163 pub status_code: u16,
164 pub latency_ms: u64,
165 pub message: String,
166}