Skip to main content

seher/sdk/
config.rs

1//! YAML config types for the Seher SDK (`providers` map).
2//!
3//! Mirrors `seher-ts/packages/sdk/src/types.ts` and the validator in `validate.ts`.
4
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8/// Per-provider API config forwarded to the underlying SDK constructor.
9#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
10pub struct ProviderApi {
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub key: Option<String>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub endpoint: Option<String>,
15}
16
17/// Opt-in / opt-out flags for skill auto-discovery.
18#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
19pub struct SkillsConfig {
20    #[serde(
21        default,
22        rename = "includeClaude",
23        skip_serializing_if = "Option::is_none"
24    )]
25    pub include_claude: Option<bool>,
26}
27
28/// Skills config with all fields resolved to concrete values.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ResolvedSkillsConfig {
31    pub include_claude: bool,
32}
33
34impl Default for ResolvedSkillsConfig {
35    fn default() -> Self {
36        Self {
37            include_claude: true,
38        }
39    }
40}
41
42/// Per-mode model entry inside a [`ProviderEntry`].
43#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
44pub struct ModelEntry {
45    pub model: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub priority: Option<i32>,
48}
49
50/// Raw model entry: either a bare string (model id) or a full struct.
51#[derive(Debug, Clone, Deserialize)]
52#[serde(untagged)]
53pub(crate) enum ModelEntryRaw {
54    Bare(String),
55    Full(ModelEntry),
56}
57
58impl From<ModelEntryRaw> for ModelEntry {
59    fn from(raw: ModelEntryRaw) -> Self {
60        match raw {
61            ModelEntryRaw::Bare(model) => Self {
62                model,
63                priority: None,
64            },
65            ModelEntryRaw::Full(m) => m,
66        }
67    }
68}
69
70/// Raw provider entry parsed from YAML before normalization.
71#[derive(Debug, Clone, Deserialize)]
72pub(crate) struct ProviderEntryRaw {
73    #[serde(default)]
74    pub provider: Option<String>,
75    #[serde(default)]
76    pub sdk: Option<String>,
77    #[serde(default)]
78    pub priority: Option<i32>,
79    #[serde(default)]
80    pub api: Option<ProviderApi>,
81    #[serde(default)]
82    pub skills: Option<SkillsConfig>,
83    pub models: IndexMap<String, ModelEntryRaw>,
84}
85
86/// A single provider in the YAML `providers` map (after normalization).
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProviderEntry {
89    /// YAML map key as written in the config (stable label).
90    pub key: String,
91    /// Insertion order in the original YAML map (for stable tiebreaks).
92    pub order: usize,
93    /// Resolved provider name. Equals the explicit `provider` field when specified
94    /// in YAML, otherwise falls back to `key`.
95    pub provider: String,
96    /// Underlying SDK kind. Always `"pi"` in this implementation (pi is the only
97    /// execution engine); kept as a string for forward compatibility.
98    pub sdk: String,
99    /// Provider-level priority shorthand.
100    pub priority: Option<i32>,
101    pub api: Option<ProviderApi>,
102    pub skills: Option<SkillsConfig>,
103    /// Mode -> model entry. Keys include `plan`, `build`, plus user-defined keys.
104    pub models: IndexMap<String, ModelEntry>,
105}
106
107/// Raw root config from YAML.
108#[derive(Debug, Clone, Deserialize)]
109pub(crate) struct ConfigRaw {
110    #[serde(default)]
111    pub providers: IndexMap<String, ProviderEntryRaw>,
112    #[serde(default)]
113    pub skills: Option<SkillsConfig>,
114}
115
116/// Normalized config root.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Config {
119    pub providers: Vec<ProviderEntry>,
120    pub skills: Option<SkillsConfig>,
121}
122
123impl Config {
124    /// Resolve effective skills config for a provider entry, falling back to root,
125    /// then to defaults (`include_claude = true`).
126    #[must_use]
127    pub fn resolve_skills(&self, entry: &ProviderEntry) -> ResolvedSkillsConfig {
128        ResolvedSkillsConfig {
129            include_claude: entry
130                .skills
131                .as_ref()
132                .and_then(|s| s.include_claude)
133                .or_else(|| self.skills.as_ref().and_then(|s| s.include_claude))
134                .unwrap_or(true),
135        }
136    }
137}
138
139impl From<ConfigRaw> for Config {
140    fn from(raw: ConfigRaw) -> Self {
141        let providers = raw
142            .providers
143            .into_iter()
144            .enumerate()
145            .map(|(order, (key, p))| {
146                let provider = p.provider.unwrap_or_else(|| key.clone());
147                let sdk = p.sdk.unwrap_or_else(|| "pi".to_string());
148                let models: IndexMap<String, ModelEntry> =
149                    p.models.into_iter().map(|(k, v)| (k, v.into())).collect();
150                ProviderEntry {
151                    key,
152                    order,
153                    provider,
154                    sdk,
155                    priority: p.priority,
156                    api: p.api,
157                    skills: p.skills,
158                    models,
159                }
160            })
161            .collect();
162        Self {
163            providers,
164            skills: raw.skills,
165        }
166    }
167}
168
169/// Output of [`resolve_agent`](crate::sdk::resolve::resolve_agent): which provider/model to use.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ResolvedAgent {
172    /// Resolved provider name (e.g., "claude", "zai").
173    pub provider: String,
174    /// Concrete model id passed to pi (e.g. "anthropic/claude-sonnet-4-5").
175    pub model_id: String,
176    /// Mode key used during resolution (plan / build / custom).
177    pub mode_key: String,
178    /// SDK kind (e.g. "pi", "claude-terminal").
179    pub sdk: String,
180    /// API config to forward.
181    pub api: Option<ProviderApi>,
182    /// Skill discovery flags resolved from per-provider > root > defaults.
183    pub skills: ResolvedSkillsConfig,
184}
185
186#[cfg(test)]
187#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn bare_model_string_parses_as_model_entry() {
193        let raw: ModelEntryRaw = serde_yaml::from_str("opus-4.7").expect("parse");
194        let entry: ModelEntry = raw.into();
195        assert_eq!(entry.model, "opus-4.7");
196        assert_eq!(entry.priority, None);
197    }
198
199    #[test]
200    fn full_model_entry_parses_with_priority() {
201        let raw: ModelEntryRaw =
202            serde_yaml::from_str("{ model: opus-4.7, priority: 5 }").expect("parse");
203        let entry: ModelEntry = raw.into();
204        assert_eq!(entry.model, "opus-4.7");
205        assert_eq!(entry.priority, Some(5));
206    }
207
208    #[test]
209    fn provider_key_defaults_to_provider_name() {
210        let yaml = "
211providers:
212  claude:
213    models:
214      build: opus-4.7
215";
216        let raw: ConfigRaw = serde_yaml::from_str(yaml).expect("parse");
217        let cfg: Config = raw.into();
218        assert_eq!(cfg.providers.len(), 1);
219        assert_eq!(cfg.providers[0].key, "claude");
220        assert_eq!(cfg.providers[0].provider, "claude");
221        assert_eq!(cfg.providers[0].sdk, "pi");
222    }
223
224    #[test]
225    fn explicit_provider_overrides_key() {
226        let yaml = "
227providers:
228  zai:
229    provider: zai
230    sdk: pi
231    api:
232      key: sk-test
233      endpoint: https://api.zai.example.com
234    models:
235      build: zai/glm-5.1
236";
237        let raw: ConfigRaw = serde_yaml::from_str(yaml).expect("parse");
238        let cfg: Config = raw.into();
239        let zai = &cfg.providers[0];
240        assert_eq!(zai.key, "zai");
241        assert_eq!(zai.provider, "zai");
242        assert_eq!(
243            zai.api.as_ref().and_then(|a| a.key.as_deref()),
244            Some("sk-test")
245        );
246        assert_eq!(
247            zai.api.as_ref().and_then(|a| a.endpoint.as_deref()),
248            Some("https://api.zai.example.com"),
249        );
250        assert_eq!(zai.models["build"].model, "zai/glm-5.1");
251    }
252
253    #[test]
254    fn provider_order_is_preserved() {
255        let yaml = "
256providers:
257  codex:
258    models: { build: gpt-5.5 }
259  claude:
260    models: { build: opus-4.7 }
261  cursor:
262    models: { build: cursor/some-model }
263";
264        let raw: ConfigRaw = serde_yaml::from_str(yaml).expect("parse");
265        let cfg: Config = raw.into();
266        let keys: Vec<&str> = cfg.providers.iter().map(|p| p.key.as_str()).collect();
267        assert_eq!(keys, vec!["codex", "claude", "cursor"]);
268        let orders: Vec<usize> = cfg.providers.iter().map(|p| p.order).collect();
269        assert_eq!(orders, vec![0, 1, 2]);
270    }
271
272    #[test]
273    fn skills_resolution_falls_through_to_default() {
274        let cfg = Config::default();
275        let entry = ProviderEntry {
276            key: "x".into(),
277            order: 0,
278            provider: "x".into(),
279            sdk: "pi".into(),
280            priority: None,
281            api: None,
282            skills: None,
283            models: IndexMap::new(),
284        };
285        assert!(cfg.resolve_skills(&entry).include_claude);
286    }
287
288    #[test]
289    fn skills_resolution_per_provider_overrides_root() {
290        let cfg = Config {
291            providers: vec![],
292            skills: Some(SkillsConfig {
293                include_claude: Some(false),
294            }),
295        };
296        let entry = ProviderEntry {
297            key: "x".into(),
298            order: 0,
299            provider: "x".into(),
300            sdk: "pi".into(),
301            priority: None,
302            api: None,
303            skills: Some(SkillsConfig {
304                include_claude: Some(true),
305            }),
306            models: IndexMap::new(),
307        };
308        assert!(cfg.resolve_skills(&entry).include_claude);
309    }
310}