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    /// Command to run — can be a single string (with separate `args`)
318    /// or an array of strings (command + args combined).
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub command: Option<CommandField>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub args: Option<Vec<String>>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub url: Option<String>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub env: Option<HashMap<String, String>>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub enabled: Option<bool>,
329}
330
331/// MCP command field — accepts either a string or an array of strings.
332///
333/// OpenCode's config format supports both:
334/// - `"command": "npx"` with `"args": ["-y", "some-pkg"]`
335/// - `"command": ["npx", "-y", "some-pkg"]` (shorthand without separate args)
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
337#[serde(untagged)]
338pub enum CommandField {
339    String(String),
340    Array(Vec<String>),
341}
342
343/// Permission configuration.
344///
345/// Supports both simple string values ("ask", "allow", "deny") and
346/// nested object rules per tool.
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348#[serde(untagged)]
349pub enum PermissionConfig {
350    /// Simple permission for all tools.
351    Simple(PermissionAction),
352    /// Per-tool permission rules.
353    Detailed(HashMap<String, PermissionRule>),
354}
355
356/// Permission action.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359pub enum PermissionAction {
360    Ask,
361    Allow,
362    Deny,
363}
364
365/// Permission rule for a specific tool.
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
367#[serde(untagged)]
368pub enum PermissionRule {
369    /// Simple action for all sub-operations.
370    Simple(PermissionAction),
371    /// Per-pattern rules (e.g., bash: { "rm -rf *": "deny", "*": "ask" }).
372    Detailed(HashMap<String, PermissionAction>),
373}
374
375/// Formatter configuration.
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(rename_all = "camelCase")]
378pub struct FormatterConfig {
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub disabled: Option<bool>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub command: Option<Vec<String>>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub environment: Option<HashMap<String, String>>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub extensions: Option<Vec<String>>,
387}
388
389/// Compaction configuration.
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
391#[serde(rename_all = "camelCase")]
392pub struct CompactionConfig {
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub auto: Option<bool>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub prune: Option<bool>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub reserved: Option<u64>,
399}
400
401/// Share mode.
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403#[serde(rename_all = "lowercase")]
404pub enum ShareMode {
405    Manual,
406    Auto,
407    Disabled,
408}
409
410/// Autoupdate configuration.
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412#[serde(untagged)]
413pub enum AutoupdateConfig {
414    Bool(bool),
415    Notify(String),
416}
417
418/// Plugin entry - can be a string or [string, options] tuple.
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
420#[serde(untagged)]
421pub enum PluginEntry {
422    Name(String),
423    WithOptions(Vec<serde_json::Value>),
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_provider_config_deserialize() {
432        let json = r#"{
433            "npm": "@ai-sdk/openai-compatible",
434            "name": "My Custom Provider",
435            "options": {
436                "baseURL": "http://127.0.0.1:1234/v1"
437            },
438            "models": {
439                "gpt-4o": {
440                    "name": "GPT-4o"
441                }
442            }
443        }"#;
444
445        let config: ProviderConfig = serde_json::from_str(json).unwrap();
446        assert_eq!(config.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
447        assert_eq!(config.name.as_deref(), Some("My Custom Provider"));
448        assert!(config.models.is_some());
449        assert!(config.options.is_some());
450    }
451
452    #[test]
453    fn test_opencode_config_deserialize_minimal() {
454        let json = r#"{
455            "$schema": "https://opencode.ai/config.json",
456            "model": "anthropic/claude-sonnet-4-5"
457        }"#;
458
459        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
460        assert_eq!(
461            config.schema.as_deref(),
462            Some("https://opencode.ai/config.json")
463        );
464        assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
465    }
466
467    #[test]
468    fn test_opencode_config_serialize_roundtrip() {
469        let json = r#"{
470            "$schema": "https://opencode.ai/config.json",
471            "provider": {
472                "anthropic": {
473                    "options": {
474                        "apiKey": "{env:ANTHROPIC_API_KEY}"
475                    }
476                }
477            },
478            "model": "anthropic/claude-sonnet-4-5",
479            "smallModel": "anthropic/claude-haiku-4-5",
480            "autoupdate": true
481        }"#;
482
483        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
484        let serialized = serde_json::to_string_pretty(&config).unwrap();
485        let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
486        assert_eq!(config, deserialized);
487    }
488
489    #[test]
490    fn test_log_level_deserialize() {
491        let json = r#""WARN""#;
492        let level: LogLevel = serde_json::from_str(json).unwrap();
493        assert_eq!(level, LogLevel::Warn);
494    }
495
496    #[test]
497    fn test_share_mode_deserialize() {
498        assert_eq!(
499            serde_json::from_str::<ShareMode>(r#""manual""#).unwrap(),
500            ShareMode::Manual
501        );
502        assert_eq!(
503            serde_json::from_str::<ShareMode>(r#""disabled""#).unwrap(),
504            ShareMode::Disabled
505        );
506    }
507
508    #[test]
509    fn test_plugin_entry_variants() {
510        let name: PluginEntry = serde_json::from_str(r#""my-plugin""#).unwrap();
511        assert!(matches!(name, PluginEntry::Name(_)));
512
513        let with_opts: PluginEntry =
514            serde_json::from_str(r#"["my-plugin", {"key": "value"}]"#).unwrap();
515        assert!(matches!(with_opts, PluginEntry::WithOptions(_)));
516    }
517
518    #[test]
519    fn test_autoupdate_config_variants() {
520        let bool_val: AutoupdateConfig = serde_json::from_str(r#"true"#).unwrap();
521        assert!(matches!(bool_val, AutoupdateConfig::Bool(true)));
522
523        let notify_val: AutoupdateConfig = serde_json::from_str(r#""notify""#).unwrap();
524        assert!(matches!(notify_val, AutoupdateConfig::Notify(_)));
525    }
526
527    #[test]
528    fn test_unknown_fields_preserved_in_extra() {
529        // Simulates a config with fields our schema doesn't model yet.
530        // The "theme" and "customFeature" fields must survive round-trip.
531        let json = r#"{
532            "$schema": "https://opencode.ai/config.json",
533            "model": "anthropic/claude-sonnet-4-5",
534            "provider": {
535                "openai": {
536                    "npm": "openai"
537                }
538            },
539            "theme": "dark",
540            "customFeature": { "enabled": true, "level": 42 }
541        }"#;
542
543        let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
544        // Known fields parsed correctly
545        assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
546        assert!(config.provider.is_some());
547        // Unknown fields captured in extra
548        assert_eq!(
549            config.extra.get("theme").and_then(|v| v.as_str()),
550            Some("dark")
551        );
552        assert_eq!(
553            config
554                .extra
555                .get("customFeature")
556                .and_then(|v| v.get("enabled"))
557                .and_then(|v| v.as_bool()),
558            Some(true)
559        );
560
561        // Round-trip: serialize back and verify unknown fields survive
562        let serialized = serde_json::to_string_pretty(&config).unwrap();
563        let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
564        assert_eq!(
565            deserialized.extra.get("theme").and_then(|v| v.as_str()),
566            Some("dark")
567        );
568        assert_eq!(
569            deserialized
570                .extra
571                .get("customFeature")
572                .and_then(|v| v.get("level"))
573                .and_then(|v| v.as_i64()),
574            Some(42)
575        );
576    }
577}