Skip to main content

systemprompt_models/bridge/opencode/
mod.rs

1//! The `OpenCode` V2 `opencode.json` shape, rendered by one builder.
2//!
3//! Both the bridge (user or managed tier on a device) and the headless
4//! `agent-runs` host (an isolated run home) emit this document, so the keys
5//! cannot drift between them. Only the V2 shape (`@opencode/cli` >= 2.0.30)
6//! is modelled: `providers.<id>{package, settings, models}`, `policies`,
7//! `mcp.servers`.
8//!
9//! `policies` are evaluated last-match-wins by `OpenCode`; a `tool.use`
10//! resource is `<tool>:<pattern>` and an `integration.use` resource is
11//! `mcp:<name>`, `plugin:<id or path>` or `skill:<id>`. [`lockdown_policies`]
12//! relies on that ordering: each allow follows the deny it carves out of. A
13//! plugin allowed by path is safe only where the user cannot write that path.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18pub mod agents_md;
19mod policy;
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25pub use policy::{Lockdown, Policy, PolicyAction, PolicyEffect, lockdown_policies};
26
27pub const SCHEMA_URL: &str = "https://opencode.ai/config.json";
28
29pub const OPENAI_COMPATIBLE_PACKAGE: &str = "aisdk:@ai-sdk/openai-compatible";
30
31pub const MIN_CLIENT_VERSION: semver::Version = semver::Version::new(2, 0, 30);
32
33/// A whole V2 `opencode.json` document.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct OpenCodeConfig {
36    #[serde(rename = "$schema")]
37    pub schema: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub model: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub update: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub share: Option<String>,
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    pub providers: BTreeMap<String, Provider>,
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub policies: Vec<Policy>,
48    #[serde(default, skip_serializing_if = "McpBlock::is_empty")]
49    pub mcp: McpBlock,
50}
51
52impl Default for OpenCodeConfig {
53    fn default() -> Self {
54        Self {
55            schema: SCHEMA_URL.to_owned(),
56            model: None,
57            update: Some("disable".to_owned()),
58            share: Some("disabled".to_owned()),
59            providers: BTreeMap::new(),
60            policies: Vec::new(),
61            mcp: McpBlock::default(),
62        }
63    }
64}
65
66impl OpenCodeConfig {
67    #[must_use]
68    pub fn with_provider(mut self, id: &str, provider: Provider) -> Self {
69        self.providers.insert(id.to_owned(), provider);
70        self
71    }
72
73    #[must_use]
74    pub fn with_model(mut self, provider: &str, alias: &str) -> Self {
75        self.model = Some(format!("{provider}/{alias}"));
76        self
77    }
78
79    #[must_use]
80    pub fn with_policies(mut self, policies: Vec<Policy>) -> Self {
81        self.policies = policies;
82        self
83    }
84
85    #[must_use]
86    pub fn with_mcp_server(mut self, name: &str, server: McpServer) -> Self {
87        self.mcp.servers.insert(name.to_owned(), server);
88        self
89    }
90
91    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
92        serde_json::to_string_pretty(self).map(|mut text| {
93            text.push('\n');
94            text
95        })
96    }
97}
98
99/// One `providers.<id>` entry.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct Provider {
102    pub name: String,
103    pub package: String,
104    pub settings: ProviderSettings,
105    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
106    pub models: BTreeMap<String, ModelEntry>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ProviderSettings {
111    #[serde(rename = "baseURL")]
112    pub base_url: String,
113    #[serde(rename = "apiKey")]
114    pub api_key: String,
115    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
116    pub headers: BTreeMap<String, String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct ModelEntry {
121    #[serde(rename = "modelID")]
122    pub model: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub name: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub limit: Option<ModelLimit>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ModelLimit {
131    pub context: u64,
132    pub output: u64,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct McpBlock {
137    #[serde(default)]
138    pub servers: BTreeMap<String, McpServer>,
139}
140
141impl McpBlock {
142    #[must_use]
143    pub fn is_empty(&self) -> bool {
144        self.servers.is_empty()
145    }
146}
147
148/// One `mcp.servers.<name>` entry.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(tag = "type", rename_all = "snake_case")]
151pub enum McpServer {
152    Remote {
153        url: String,
154        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
155        headers: BTreeMap<String, String>,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        codemode: Option<bool>,
158        #[serde(default, skip_serializing_if = "Option::is_none")]
159        oauth: Option<McpOAuth>,
160    },
161    Local {
162        command: Vec<String>,
163        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
164        environment: BTreeMap<String, String>,
165    },
166}
167
168/// A remote server's `oauth` setting. `OpenCode` registers every remote server
169/// as an OAuth integration and answers a 401 with client registration unless
170/// `oauth` is `false`; only that opt-out is modelled.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum McpOAuth {
173    Disabled,
174}
175
176impl Serialize for McpOAuth {
177    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
178        serializer.serialize_bool(false)
179    }
180}
181
182impl<'de> Deserialize<'de> for McpOAuth {
183    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
184        if bool::deserialize(deserializer)? {
185            return Err(serde::de::Error::custom(
186                "only `oauth: false` is modelled for a remote MCP server",
187            ));
188        }
189        Ok(Self::Disabled)
190    }
191}
192
193/// Where `OpenCode` reads a secret from at load time; never the secret.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ApiKeyRef {
196    Env(String),
197    File(String),
198}
199
200impl ApiKeyRef {
201    #[must_use]
202    pub fn render(&self) -> String {
203        match self {
204            Self::Env(var) => format!("{{env:{var}}}"),
205            Self::File(path) => format!("{{file:{path}}}"),
206        }
207    }
208}
209
210#[must_use]
211pub fn gateway_provider(
212    base_url: &str,
213    api_key: &ApiKeyRef,
214    headers: BTreeMap<String, String>,
215    models: BTreeMap<String, ModelEntry>,
216) -> Provider {
217    Provider {
218        name: "systemprompt".to_owned(),
219        package: OPENAI_COMPATIBLE_PACKAGE.to_owned(),
220        settings: ProviderSettings {
221            base_url: format!("{}/v1", base_url.trim_end_matches('/')),
222            api_key: api_key.render(),
223            headers,
224        },
225        models,
226    }
227}