1use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProviderEntry {
89 pub key: String,
91 pub order: usize,
93 pub provider: String,
96 pub sdk: String,
99 pub priority: Option<i32>,
101 pub api: Option<ProviderApi>,
102 pub skills: Option<SkillsConfig>,
103 pub models: IndexMap<String, ModelEntry>,
105}
106
107#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Config {
119 pub providers: Vec<ProviderEntry>,
120 pub skills: Option<SkillsConfig>,
121}
122
123impl Config {
124 #[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#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ResolvedAgent {
172 pub provider: String,
174 pub model_id: String,
176 pub mode_key: String,
178 pub sdk: String,
180 pub api: Option<ProviderApi>,
182 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}