Skip to main content

provider_agent/
config.rs

1//! `agent.toml` parsing and validation. See `plan/V2_AGENT_SPEC.md` §3.
2
3use 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    /// Operator block is metadata only. Populated by `enroll` in v0.1.x; in
12    /// the v0.2.x pairing-code flow the dashboard owns operator identity
13    /// and the agent only needs coordinator URL + backends + identity. The
14    /// `setup` subcommand writes a placeholder so the field stays present
15    /// for back-compat with v0.1.x configs.
16    #[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    /// Resolve `~` and return the absolute path to the identity file.
65    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
132/// Load and validate the agent config.
133///
134/// Lookup order if `path` is None:
135///   1. `$XDG_CONFIG_HOME/usepod-agent/agent.toml` (or platform equivalent)
136///   2. `./agent.toml`
137pub 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
181/// Validate per spec §3.1.
182pub fn validate(cfg: &Config, allow_insecure: bool) -> Result<()> {
183    // coordinator URL scheme
184    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    // backends: unique kind+url / kind+api_key_env, per-kind validation
194    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                // Per spec, presence in env is checked at startup. We don't fail validate-only
211                // runs on a missing env var; the operator may be testing the config offline.
212                (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    // pricing required (defaults)
222    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    // limits
227    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}