1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use serde::Deserialize;
8
9#[derive(Debug, Deserialize)]
10pub struct Config {
11 #[serde(default)]
17 pub operator: Operator,
18 pub coordinator: Coordinator,
19 #[serde(default)]
20 pub identity: Identity,
21 #[serde(default, rename = "backends")]
22 pub backends: Vec<Backend>,
23 pub pricing: Pricing,
24 #[serde(default)]
25 pub limits: Limits,
26 #[serde(default)]
27 pub observability: Observability,
28}
29
30#[derive(Debug, Deserialize, Default)]
31pub struct Operator {
32 #[serde(default)]
33 pub display_name: String,
34 #[serde(default)]
35 pub wallet: String,
36 #[serde(default)]
37 pub contact_email: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct Coordinator {
42 pub url: String,
43 #[serde(default)]
44 pub enrollment_code: Option<String>,
45}
46
47#[derive(Debug, Deserialize)]
48pub struct Identity {
49 #[serde(default = "default_key_path")]
50 pub key_path: String,
51}
52
53impl Default for Identity {
54 fn default() -> Self {
55 Self { key_path: default_key_path() }
56 }
57}
58
59fn default_key_path() -> String {
60 "~/.usepod-agent/identity.key".to_string()
61}
62
63impl Identity {
64 pub fn expanded_key_path(&self) -> Result<PathBuf> {
66 expand_home(&self.key_path)
67 }
68}
69
70#[derive(Debug, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Backend {
73 pub kind: String,
74 #[serde(default)]
75 pub url: Option<String>,
76 #[serde(default)]
77 pub api_key_env: Option<String>,
78 #[serde(default)]
79 pub markup: Option<f64>,
80 #[serde(default)]
81 pub models: Option<Vec<String>>,
82}
83
84#[derive(Debug, Deserialize)]
85pub struct Pricing {
86 pub default_input_per_1m: u64,
87 pub default_output_per_1m: u64,
88 #[serde(default)]
89 pub models: std::collections::BTreeMap<String, ModelPrice>,
90}
91
92#[derive(Debug, Deserialize)]
93pub struct ModelPrice {
94 pub input_per_1m: u64,
95 pub output_per_1m: u64,
96}
97
98#[derive(Debug, Deserialize)]
99pub struct Limits {
100 #[serde(default = "default_max_concurrent")]
101 pub max_concurrent: u32,
102 #[serde(default)]
103 pub max_tokens_per_minute: Option<u64>,
104}
105
106impl Default for Limits {
107 fn default() -> Self {
108 Self { max_concurrent: default_max_concurrent(), max_tokens_per_minute: None }
109 }
110}
111
112fn default_max_concurrent() -> u32 {
113 8
114}
115
116#[derive(Debug, Deserialize, Default)]
117pub struct Observability {
118 #[serde(default = "default_prom_addr")]
119 pub prometheus_addr: String,
120 #[serde(default = "default_log_level")]
121 pub log_level: String,
122}
123
124fn default_prom_addr() -> String {
125 "127.0.0.1:9090".to_string()
126}
127
128fn default_log_level() -> String {
129 "info".to_string()
130}
131
132pub fn load(path: Option<&Path>, allow_insecure: bool) -> Result<Config> {
138 let resolved: PathBuf = match path {
139 Some(p) => p.to_path_buf(),
140 None => default_config_path()?,
141 };
142 let raw = std::fs::read_to_string(&resolved)
143 .with_context(|| format!("reading config from {}", resolved.display()))?;
144 let cfg: Config = toml::from_str(&raw)
145 .with_context(|| format!("parsing TOML in {}", resolved.display()))?;
146 validate(&cfg, allow_insecure)?;
147 Ok(cfg)
148}
149
150fn default_config_path() -> Result<PathBuf> {
151 if let Some(dirs) = directories::ProjectDirs::from("ai", "usepod", "usepod-agent") {
152 let p = dirs.config_dir().join("agent.toml");
153 if p.exists() {
154 return Ok(p);
155 }
156 }
157 let cwd = std::env::current_dir()?.join("agent.toml");
158 if cwd.exists() {
159 return Ok(cwd);
160 }
161 bail!(
162 "no agent.toml found; pass --config or place one at \
163 $XDG_CONFIG_HOME/usepod-agent/agent.toml or ./agent.toml"
164 )
165}
166
167fn expand_home(p: &str) -> Result<PathBuf> {
168 if let Some(rest) = p.strip_prefix("~/") {
169 let dirs =
170 directories::UserDirs::new().ok_or_else(|| anyhow!("could not resolve home dir"))?;
171 return Ok(dirs.home_dir().join(rest));
172 }
173 if p == "~" {
174 let dirs =
175 directories::UserDirs::new().ok_or_else(|| anyhow!("could not resolve home dir"))?;
176 return Ok(dirs.home_dir().to_path_buf());
177 }
178 Ok(PathBuf::from(p))
179}
180
181pub fn validate(cfg: &Config, allow_insecure: bool) -> Result<()> {
183 let parsed = url::Url::parse(&cfg.coordinator.url)
185 .with_context(|| format!("invalid coordinator.url: {}", cfg.coordinator.url))?;
186 match parsed.scheme() {
187 "wss" => {}
188 "ws" if allow_insecure => {}
189 "ws" => bail!("coordinator.url must be wss:// in production (use --allow-insecure to override)"),
190 other => bail!("coordinator.url scheme must be wss or ws, got {other}"),
191 }
192
193 let mut seen: HashSet<(String, String)> = HashSet::new();
195 for (i, b) in cfg.backends.iter().enumerate() {
196 let key = match b.kind.as_str() {
197 "vllm" | "llamacpp" | "lmstudio" | "ollama" => {
198 let url = b
199 .url
200 .as_deref()
201 .ok_or_else(|| anyhow!("backend[{i}] kind={} requires `url`", b.kind))?;
202 url::Url::parse(url)
203 .with_context(|| format!("backend[{i}] has invalid url {url}"))?;
204 (b.kind.clone(), url.to_string())
205 }
206 "openrouter" | "venice" => {
207 let env = b.api_key_env.as_deref().ok_or_else(|| {
208 anyhow!("backend[{i}] kind={} requires `api_key_env`", b.kind)
209 })?;
210 (b.kind.clone(), env.to_string())
213 }
214 other => bail!("backend[{i}] has unknown kind {other}"),
215 };
216 if !seen.insert(key.clone()) {
217 bail!("duplicate backend entry for {} / {}", key.0, key.1);
218 }
219 }
220
221 if cfg.pricing.default_input_per_1m == 0 || cfg.pricing.default_output_per_1m == 0 {
223 bail!("pricing.default_input_per_1m and default_output_per_1m must be > 0");
224 }
225
226 if cfg.limits.max_concurrent < 1 || cfg.limits.max_concurrent > 256 {
228 bail!(
229 "limits.max_concurrent must be in [1, 256], got {}",
230 cfg.limits.max_concurrent
231 );
232 }
233
234 Ok(())
235}