Skip to main content

opencode_provider_manager/config_core/
schema.rs

1//! Config schema types matching the OpenCode JSON schema.
2//!
3//! Reference: https://opencode.ai/config.json
4//! These types are designed for serde serialization/deserialization
5//! and support partial configs (all fields optional) for merge operations.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Root configuration for `opencode.json`.
11///
12/// All fields are optional to support partial configs from different layers
13/// (global, project, custom) that are merged together.
14#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct OpenCodeConfig {
17    /// JSON schema reference for validation.
18    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
19    pub schema: Option<String>,
20
21    /// Log level.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub log_level: Option<LogLevel>,
24
25    /// Server configuration.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub server: Option<ServerConfig>,
28
29    /// Custom commands.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub command: Option<HashMap<String, CommandConfig>>,
32
33    /// Additional skill folder paths.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub skills: Option<SkillsConfig>,
36
37    /// File watcher ignore patterns.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub watcher: Option<WatcherConfig>,
40
41    /// Enable or disable snapshot tracking.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub snapshot: Option<bool>,
44
45    /// Plugin list.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub plugin: Option<Vec<PluginEntry>>,
48
49    /// Sharing behavior.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub share: Option<ShareMode>,
52
53    /// Auto-update behavior.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub autoupdate: Option<AutoupdateConfig>,
56
57    /// Disabled provider IDs.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub disabled_providers: Option<Vec<String>>,
60
61    /// Enabled provider IDs (allowlist).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub enabled_providers: Option<Vec<String>>,
64
65    /// Default model in `provider/model` format.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub model: Option<String>,
68
69    /// Small model for lightweight tasks.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub small_model: Option<String>,
72
73    /// Default agent.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub default_agent: Option<String>,
76
77    /// Custom username.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub username: Option<String>,
80
81    /// Agent configurations.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub agent: Option<HashMap<String, AgentConfig>>,
84
85    /// Provider configurations.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub provider: Option<HashMap<String, ProviderConfig>>,
88
89    /// MCP server configurations.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub mcp: Option<HashMap<String, McpConfig>>,
92
93    /// Tool permissions.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub permission: Option<PermissionConfig>,
96
97    /// Formatter configurations.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub formatter: Option<HashMap<String, FormatterConfig>>,
100
101    /// Instruction file paths/globs.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub instructions: Option<Vec<String>>,
104
105    /// Compaction settings.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub compaction: Option<CompactionConfig>,
108
109    /// Experimental features.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub experimental: Option<serde_json::Value>,
112
113    /// Tool enable/disable overrides.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub tools: Option<HashMap<String, bool>>,
116
117    /// Catch-all for fields not explicitly modeled above.
118    /// Prevents data loss when reading configs with fields our schema
119    /// doesn't cover yet (e.g., new OpenCode features).
120    #[serde(flatten)]
121    pub extra: HashMap<String, serde_json::Value>,
122}
123
124/// Log levels matching OpenCode config schema.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
127pub enum LogLevel {
128    Debug,
129    Info,
130    Warn,
131    Error,
132}
133
134/// Server configuration.
135#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
136#[serde(rename_all = "camelCase")]
137pub struct ServerConfig {
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub port: Option<u16>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub hostname: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub mdns: Option<bool>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub mdns_domain: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub cors: Option<Vec<String>>,
148}
149
150/// Custom command configuration.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct CommandConfig {
154    pub template: String,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub description: Option<String>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub agent: Option<String>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub model: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub subtask: Option<bool>,
163}
164
165/// Skills configuration.
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167#[serde(rename_all = "camelCase")]
168pub struct SkillsConfig {
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub paths: Option<Vec<String>>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub urls: Option<Vec<String>>,
173}
174
175/// Watcher configuration.
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177pub struct WatcherConfig {
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub ignore: Option<Vec<String>>,
180}
181
182/// Provider configuration.
183///
184/// This is the core type for the provider manager, representing a single
185/// provider entry in `opencode.json` under the `provider` key.
186#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
187#[serde(rename_all = "camelCase")]
188pub struct ProviderConfig {
189    /// NPM package for custom providers (e.g., "@ai-sdk/openai-compatible").
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub npm: Option<String>,
192
193    /// Display name for the provider in the UI.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub name: Option<String>,
196
197    /// Provider-specific options (baseURL, apiKey, timeout, etc.).
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub options: Option<HashMap<String, serde_json::Value>>,
200
201    /// Models available under this provider.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub models: Option<HashMap<String, ModelConfig>>,
204
205    /// Provider-specific disabled flag.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub disabled: Option<bool>,
208
209    /// Unknown provider fields preserved during import/round-trip.
210    #[serde(flatten)]
211    pub extra: HashMap<String, serde_json::Value>,
212}
213
214/// Model configuration within a provider.
215#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
216#[serde(rename_all = "camelCase")]
217pub struct ModelConfig {
218    /// Display name for the model.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub name: Option<String>,
221
222    /// Model ID override (for custom inference profiles, etc.).
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub id: Option<String>,
225
226    /// Model-specific options (reasoningEffort, thinking, etc.).
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub options: Option<HashMap<String, serde_json::Value>>,
229
230    /// Model variants with different configurations.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub variants: Option<HashMap<String, VariantConfig>>,
233
234    /// Model limits.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub limit: Option<ModelLimit>,
237
238    /// Whether this model/variant is disabled.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub disabled: Option<bool>,
241
242    /// Unknown model metadata preserved during import/round-trip.
243    #[serde(flatten)]
244    pub extra: HashMap<String, serde_json::Value>,
245}
246
247/// Model limits for context and output tokens.
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249pub struct ModelLimit {
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub context: Option<u64>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub output: Option<u64>,
254}
255
256/// Variant configuration for a model.
257#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
258pub struct VariantConfig {
259    #[serde(flatten)]
260    pub options: HashMap<String, serde_json::Value>,
261
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub disabled: Option<bool>,
264}
265
266/// Agent configuration.
267#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct AgentConfig {
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub model: Option<String>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub variant: Option<String>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub temperature: Option<f64>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub top_p: Option<f64>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub prompt: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub description: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub disable: Option<bool>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub mode: Option<AgentMode>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub hidden: Option<bool>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub steps: Option<u64>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub color: Option<String>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub options: Option<HashMap<String, serde_json::Value>>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub permission: Option<PermissionConfig>,
296
297    /// Deprecated: use `permission` field instead.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub tools: Option<HashMap<String, bool>>,
300}
301
302/// Agent mode.
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304#[serde(rename_all = "lowercase")]
305pub enum AgentMode {
306    Subagent,
307    Primary,
308    All,
309}
310
311/// MCP server configuration.
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
313#[serde(rename_all = "camelCase")]
314pub struct McpConfig {
315    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
316    pub mcp_type: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub command: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub args: Option<Vec<String>>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub url: Option<String>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub env: Option<HashMap<String, String>>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub enabled: Option<bool>,
327}
328
329/// Permission configuration.
330///
331/// Supports both simple string values ("ask", "allow", "deny") and
332/// nested object rules per tool.
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
334#[serde(untagged)]
335pub enum PermissionConfig {
336    /// Simple permission for all tools.
337    Simple(PermissionAction),
338    /// Per-tool permission rules.
339    Detailed(HashMap<String, PermissionRule>),
340}
341
342/// Permission action.
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "lowercase")]
345pub enum PermissionAction {
346    Ask,
347    Allow,
348    Deny,
349}
350
351/// Permission rule for a specific tool.
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353#[serde(untagged)]
354pub enum PermissionRule {
355    /// Simple action for all sub-operations.
356    Simple(PermissionAction),
357    /// Per-pattern rules (e.g., bash: { "rm -rf *": "deny", "*": "ask" }).
358    Detailed(HashMap<String, PermissionAction>),
359}
360
361/// Formatter configuration.
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363#[serde(rename_all = "camelCase")]
364pub struct FormatterConfig {
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub disabled: Option<bool>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub command: Option<Vec<String>>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub environment: Option<HashMap<String, String>>,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub extensions: Option<Vec<String>>,
373}
374
375/// Compaction configuration.
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(rename_all = "camelCase")]
378pub struct CompactionConfig {
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub auto: Option<bool>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub prune: Option<bool>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub reserved: Option<u64>,
385}
386
387/// Share mode.
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
389#[serde(rename_all = "lowercase")]
390pub enum ShareMode {
391    Manual,
392    Auto,
393    Disabled,
394}
395
396/// Autoupdate configuration.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(untagged)]
399pub enum AutoupdateConfig {
400    Bool(bool),
401    Notify(String),
402}
403
404/// Plugin entry - can be a string or [string, options] tuple.
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
406#[serde(untagged)]
407pub enum PluginEntry {
408    Name(String),
409    WithOptions(Vec<serde_json::Value>),
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_provider_config_deserialize() {
418        let json = r#"{
419            "npm": "@ai-sdk/openai-compatible",
420            "name": "My Custom Provider",
421            "options": {
422                "baseURL": "http://127.0.0.1:1234/v1"
423            },
424            "models": {
425                "gpt-4o": {
426                    "name": "GPT-4o"
427                }
428            }
429        }"#;
430
431        let config: ProviderConfig = serde_json::from_str(json).unwrap();
432        assert_eq!(config.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
433        assert_eq!(config.name.as_deref(), Some("My Custom Provider"));
434        assert!(config.models.is_some());
435        assert!(config.options.is_some());
436    }
437
438    #[test]
439    fn test_opencode_config_deserialize_minimal() {
440        let json = r#"{
441            "$schema": "https://opencode.ai/config.json",
442            "model": "anthropic/claude-sonnet-4-5"
443        }"#;
444
445        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
446        assert_eq!(
447            config.schema.as_deref(),
448            Some("https://opencode.ai/config.json")
449        );
450        assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
451    }
452
453    #[test]
454    fn test_opencode_config_serialize_roundtrip() {
455        let json = r#"{
456            "$schema": "https://opencode.ai/config.json",
457            "provider": {
458                "anthropic": {
459                    "options": {
460                        "apiKey": "{env:ANTHROPIC_API_KEY}"
461                    }
462                }
463            },
464            "model": "anthropic/claude-sonnet-4-5",
465            "smallModel": "anthropic/claude-haiku-4-5",
466            "autoupdate": true
467        }"#;
468
469        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
470        let serialized = serde_json::to_string_pretty(&config).unwrap();
471        let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
472        assert_eq!(config, deserialized);
473    }
474
475    #[test]
476    fn test_log_level_deserialize() {
477        let json = r#""WARN""#;
478        let level: LogLevel = serde_json::from_str(json).unwrap();
479        assert_eq!(level, LogLevel::Warn);
480    }
481
482    #[test]
483    fn test_share_mode_deserialize() {
484        assert_eq!(
485            serde_json::from_str::<ShareMode>(r#""manual""#).unwrap(),
486            ShareMode::Manual
487        );
488        assert_eq!(
489            serde_json::from_str::<ShareMode>(r#""disabled""#).unwrap(),
490            ShareMode::Disabled
491        );
492    }
493
494    #[test]
495    fn test_plugin_entry_variants() {
496        let name: PluginEntry = serde_json::from_str(r#""my-plugin""#).unwrap();
497        assert!(matches!(name, PluginEntry::Name(_)));
498
499        let with_opts: PluginEntry =
500            serde_json::from_str(r#"["my-plugin", {"key": "value"}]"#).unwrap();
501        assert!(matches!(with_opts, PluginEntry::WithOptions(_)));
502    }
503
504    #[test]
505    fn test_autoupdate_config_variants() {
506        let bool_val: AutoupdateConfig = serde_json::from_str(r#"true"#).unwrap();
507        assert!(matches!(bool_val, AutoupdateConfig::Bool(true)));
508
509        let notify_val: AutoupdateConfig = serde_json::from_str(r#""notify""#).unwrap();
510        assert!(matches!(notify_val, AutoupdateConfig::Notify(_)));
511    }
512
513    #[test]
514    fn test_unknown_fields_preserved_in_extra() {
515        // Simulates a config with fields our schema doesn't model yet.
516        // The "theme" and "customFeature" fields must survive round-trip.
517        let json = r#"{
518            "$schema": "https://opencode.ai/config.json",
519            "model": "anthropic/claude-sonnet-4-5",
520            "provider": {
521                "openai": {
522                    "npm": "openai"
523                }
524            },
525            "theme": "dark",
526            "customFeature": { "enabled": true, "level": 42 }
527        }"#;
528
529        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
530        // Known fields parsed correctly
531        assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
532        assert!(config.provider.is_some());
533        // Unknown fields captured in extra
534        assert_eq!(
535            config.extra.get("theme").and_then(|v| v.as_str()),
536            Some("dark")
537        );
538        assert_eq!(
539            config
540                .extra
541                .get("customFeature")
542                .and_then(|v| v.get("enabled"))
543                .and_then(|v| v.as_bool()),
544            Some(true)
545        );
546
547        // Round-trip: serialize back and verify unknown fields survive
548        let serialized = serde_json::to_string_pretty(&config).unwrap();
549        let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
550        assert_eq!(
551            deserialized.extra.get("theme").and_then(|v| v.as_str()),
552            Some("dark")
553        );
554        assert_eq!(
555            deserialized
556                .extra
557                .get("customFeature")
558                .and_then(|v| v.get("level"))
559                .and_then(|v| v.as_i64()),
560            Some(42)
561        );
562    }
563}