Skip to main content

zeroclaw_config/schema/
v1.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::migration::fold_string_into_array;
5use crate::schema::v2::V2Config;
6
7/// V1 partial typed lens. Names only fields that change in the V1→V2
8/// step; everything else rides through `passthrough`.
9#[derive(Debug, Default, Deserialize, Serialize)]
10pub struct V1Config {
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub api_key: Option<toml::Value>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub api_url: Option<toml::Value>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub api_path: Option<toml::Value>,
17    #[serde(
18        default,
19        skip_serializing_if = "Option::is_none",
20        alias = "model_provider"
21    )]
22    pub default_provider: Option<toml::Value>,
23    #[serde(default, skip_serializing_if = "Option::is_none", alias = "model")]
24    pub default_model: Option<toml::Value>,
25    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
26    pub model_providers: HashMap<String, toml::Value>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub default_temperature: Option<toml::Value>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub provider_timeout_secs: Option<toml::Value>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub provider_max_tokens: Option<toml::Value>,
33    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34    pub extra_headers: HashMap<String, toml::Value>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub model_routes: Vec<toml::Value>,
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub embedding_routes: Vec<toml::Value>,
39
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub channels_config: Option<toml::Value>,
42
43    #[serde(flatten)]
44    pub passthrough: toml::Table,
45}
46
47impl V1Config {
48    pub fn migrate(self) -> V2Config {
49        let V1Config {
50            api_key,
51            api_url,
52            api_path,
53            default_provider,
54            default_model,
55            model_providers,
56            default_temperature,
57            provider_timeout_secs,
58            provider_max_tokens,
59            extra_headers,
60            model_routes,
61            embedding_routes,
62            channels_config,
63            mut passthrough,
64        } = self;
65
66        // V1 had provider knobs at the top level; V2 moved them per-provider.
67        // Fold each into the ModelProviderConfig entry identified by V1's
68        // default_provider key with field renames as below.
69        let has_v1_providers_data = default_provider.is_some()
70            || default_model.is_some()
71            || api_key.is_some()
72            || api_url.is_some()
73            || api_path.is_some()
74            || default_temperature.is_some()
75            || provider_timeout_secs.is_some()
76            || provider_max_tokens.is_some()
77            || !extra_headers.is_empty()
78            || !model_providers.is_empty()
79            || !model_routes.is_empty()
80            || !embedding_routes.is_empty();
81
82        let providers_value = if !has_v1_providers_data {
83            None
84        } else {
85            // V1 runtime hardcoded "openrouter" as the fallback when
86            // default_provider was unset; preserve that so a stock V1 install
87            // round-trips.
88            let default_provider_key: String = default_provider
89                .as_ref()
90                .and_then(|v| v.as_str())
91                .map(str::to_string)
92                .unwrap_or_else(|| "openrouter".to_string());
93
94            let mut models_table: toml::Table = model_providers.into_iter().collect();
95
96            let needs_fold = api_key.is_some()
97                || api_url.is_some()
98                || api_path.is_some()
99                || default_model.is_some()
100                || default_temperature.is_some()
101                || provider_timeout_secs.is_some()
102                || provider_max_tokens.is_some()
103                || !extra_headers.is_empty();
104
105            if needs_fold {
106                let entry_value = models_table
107                    .remove(&default_provider_key)
108                    .unwrap_or_else(|| toml::Value::Table(toml::Table::new()));
109                let mut entry_table = match entry_value {
110                    toml::Value::Table(t) => t,
111                    other => {
112                        // Preserve verbatim; nothing to fold into a non-table.
113                        models_table.insert(default_provider_key.clone(), other);
114                        toml::Table::new()
115                    }
116                };
117
118                // or_insert so any value the user already set on the
119                // per-provider entry wins over the V1 top-level global —
120                // matches V1 runtime preference (per-provider > global).
121                if let Some(v) = api_key {
122                    entry_table.entry("api_key".to_string()).or_insert(v);
123                }
124                if let Some(v) = api_url {
125                    entry_table.entry("base_url".to_string()).or_insert(v);
126                }
127                if let Some(v) = api_path {
128                    entry_table.entry("api_path".to_string()).or_insert(v);
129                }
130                if let Some(v) = default_model {
131                    entry_table.entry("model".to_string()).or_insert(v);
132                }
133                if let Some(v) = default_temperature {
134                    entry_table.entry("temperature".to_string()).or_insert(v);
135                }
136                if let Some(v) = provider_timeout_secs {
137                    entry_table.entry("timeout_secs".to_string()).or_insert(v);
138                }
139                if let Some(v) = provider_max_tokens {
140                    entry_table.entry("max_tokens".to_string()).or_insert(v);
141                }
142                if !extra_headers.is_empty() {
143                    let headers_table: toml::Table = extra_headers.into_iter().collect();
144                    entry_table
145                        .entry("extra_headers".to_string())
146                        .or_insert_with(|| toml::Value::Table(headers_table));
147                }
148
149                models_table.insert(
150                    default_provider_key.clone(),
151                    toml::Value::Table(entry_table),
152                );
153
154                ::zeroclaw_log::record!(
155                    INFO,
156                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
157                        .with_attrs(
158                            ::serde_json::json!({"default_provider_key": default_provider_key})
159                        ),
160                    "V1 top-level provider globals folded into [providers.models.]"
161                );
162            }
163
164            let mut providers = toml::Table::new();
165            providers.insert(
166                "fallback".to_string(),
167                toml::Value::String(default_provider_key),
168            );
169            if !models_table.is_empty() {
170                providers.insert("models".to_string(), toml::Value::Table(models_table));
171            }
172            if !model_routes.is_empty() {
173                providers.insert("model_routes".to_string(), toml::Value::Array(model_routes));
174            }
175            if !embedding_routes.is_empty() {
176                providers.insert(
177                    "embedding_routes".to_string(),
178                    toml::Value::Array(embedding_routes),
179                );
180            }
181            Some(toml::Value::Table(providers))
182        };
183
184        // Rename channels_config → channels and apply the singular→plural
185        // folds V2 needs (matrix.room_id, slack.channel_id).
186        if let Some(mut channels_value) = channels_config {
187            if let Some(channels_table) = channels_value.as_table_mut() {
188                apply_v1_to_v2_channel_folds(channels_table);
189            }
190            passthrough.insert("channels".to_string(), channels_value);
191            ::zeroclaw_log::record!(
192                INFO,
193                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
194                "channels_config → channels"
195            );
196        }
197
198        let mut v2 = V2Config {
199            schema_version: 2,
200            providers: providers_value,
201            passthrough,
202            ..V2Config::default()
203        };
204
205        // Hoist keys that `V2Config::migrate` (V2→V3) operates on out of
206        // passthrough into the typed slots so the V2 lens sees them.
207        if let Some(v) = v2.passthrough.remove("autonomy") {
208            v2.autonomy = Some(v);
209        }
210        if let Some(v) = v2.passthrough.remove("agent") {
211            v2.agent = Some(v);
212        }
213        if let Some(toml::Value::Table(t)) = v2.passthrough.remove("swarms") {
214            v2.swarms = t.into_iter().collect();
215        }
216        if let Some(v) = v2.passthrough.remove("cron") {
217            v2.cron = Some(v);
218        }
219        if let Some(v) = v2.passthrough.remove("cost") {
220            v2.cost = Some(v);
221        }
222        if let Some(v) = v2.passthrough.remove("channels") {
223            v2.channels = Some(v);
224        }
225        if let Some(toml::Value::Table(t)) = v2.passthrough.remove("agents") {
226            v2.agents = t.into_iter().collect();
227        }
228        // Edge case: V1 user wrote a [providers] block themselves (V2
229        // section name). Merge their keys with the synthesized ones,
230        // letting the synthesized values win.
231        if let Some(toml::Value::Table(user_providers)) = v2.passthrough.remove("providers") {
232            let synthesized = v2
233                .providers
234                .take()
235                .and_then(|v| match v {
236                    toml::Value::Table(t) => Some(t),
237                    _ => None,
238                })
239                .unwrap_or_default();
240            let mut merged = user_providers;
241            for (k, v) in synthesized {
242                merged.insert(k, v);
243            }
244            if !merged.is_empty() {
245                v2.providers = Some(toml::Value::Table(merged));
246            }
247        }
248
249        v2
250    }
251}
252
253/// V2 dropped the singular `matrix.room_id` and `slack.channel_id`
254/// fields in favor of the plural `allowed_rooms[]` / `channel_ids[]`.
255/// Move the V1 singular values into the plural slots so they survive.
256fn apply_v1_to_v2_channel_folds(channels: &mut toml::Table) {
257    if let Some(toml::Value::Table(matrix)) = channels.get_mut("matrix")
258        && fold_string_into_array(matrix, "room_id", "allowed_rooms")
259    {
260        ::zeroclaw_log::record!(
261            INFO,
262            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
263            "channels.matrix.room_id folded into channels.matrix.allowed_rooms[]"
264        );
265    }
266    if let Some(toml::Value::Table(slack)) = channels.get_mut("slack")
267        && fold_string_into_array(slack, "channel_id", "channel_ids")
268    {
269        ::zeroclaw_log::record!(
270            INFO,
271            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
272            "channels.slack.channel_id folded into channels.slack.channel_ids[]"
273        );
274    }
275}