Skip to main content

systemprompt_models/
secrets.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5pub const JWT_SECRET_MIN_LENGTH: usize = 32;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Secrets {
9    pub jwt_secret: String,
10
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub manifest_signing_secret_seed: Option<String>,
13
14    pub database_url: String,
15
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub database_write_url: Option<String>,
18
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub external_database_url: Option<String>,
21
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub internal_database_url: Option<String>,
24
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub sync_token: Option<String>,
27
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub gemini: Option<String>,
30
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub anthropic: Option<String>,
33
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub openai: Option<String>,
36
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub github: Option<String>,
39
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub moonshot: Option<String>,
42
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub qwen: Option<String>,
45
46    #[serde(default, flatten)]
47    pub custom: HashMap<String, String>,
48}
49
50impl Secrets {
51    pub fn parse(content: &str) -> Result<Self> {
52        let mut value: serde_json::Value =
53            serde_json::from_str(content).context("Failed to parse secrets JSON")?;
54        if let Some(obj) = value.as_object_mut() {
55            obj.retain(|_, v| !v.is_null());
56        }
57        let secrets: Self = serde_json::from_value(value)
58            .context("Failed to deserialize secrets after null stripping")?;
59        secrets.validate()?;
60        Ok(secrets)
61    }
62
63    pub fn validate(&self) -> Result<()> {
64        if self.jwt_secret.len() < JWT_SECRET_MIN_LENGTH {
65            anyhow::bail!(
66                "jwt_secret must be at least {} characters (got {})",
67                JWT_SECRET_MIN_LENGTH,
68                self.jwt_secret.len()
69            );
70        }
71        Ok(())
72    }
73
74    pub fn effective_database_url(&self, external_db_access: bool) -> &str {
75        if external_db_access {
76            if let Some(url) = &self.external_database_url {
77                return url;
78            }
79        }
80        &self.database_url
81    }
82
83    pub const fn has_ai_provider(&self) -> bool {
84        self.gemini.is_some()
85            || self.anthropic.is_some()
86            || self.openai.is_some()
87            || self.moonshot.is_some()
88            || self.qwen.is_some()
89    }
90
91    pub fn get(&self, key: &str) -> Option<&String> {
92        match key {
93            "jwt_secret" | "JWT_SECRET" => Some(&self.jwt_secret),
94            "database_url" | "DATABASE_URL" => Some(&self.database_url),
95            "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
96            "external_database_url" | "EXTERNAL_DATABASE_URL" => {
97                self.external_database_url.as_ref()
98            },
99            "internal_database_url" | "INTERNAL_DATABASE_URL" => {
100                self.internal_database_url.as_ref()
101            },
102            "sync_token" | "SYNC_TOKEN" => self.sync_token.as_ref(),
103            "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
104            "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
105            "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
106            "github" | "GITHUB_TOKEN" => self.github.as_ref(),
107            "moonshot" | "MOONSHOT_API_KEY" | "kimi" | "KIMI_API_KEY" => self.moonshot.as_ref(),
108            "qwen" | "QWEN_API_KEY" | "dashscope" | "DASHSCOPE_API_KEY" => self.qwen.as_ref(),
109            other => self.custom.get(other).or_else(|| {
110                let alternate = if other.chars().any(char::is_uppercase) {
111                    other.to_lowercase()
112                } else {
113                    other.to_uppercase()
114                };
115                self.custom.get(&alternate)
116            }),
117        }
118    }
119
120    pub fn log_configured_providers(&self) {
121        let configured: Vec<&str> = [
122            self.gemini.as_ref().map(|_| "gemini"),
123            self.anthropic.as_ref().map(|_| "anthropic"),
124            self.openai.as_ref().map(|_| "openai"),
125            self.github.as_ref().map(|_| "github"),
126            self.moonshot.as_ref().map(|_| "moonshot"),
127            self.qwen.as_ref().map(|_| "qwen"),
128        ]
129        .into_iter()
130        .flatten()
131        .collect();
132
133        tracing::info!(providers = ?configured, "Configured API providers");
134    }
135
136    pub fn custom_env_vars(&self) -> Vec<(String, &str)> {
137        self.custom
138            .iter()
139            .flat_map(|(key, value)| {
140                let upper_key = key.to_uppercase();
141                let value_str = value.as_str();
142                if upper_key == *key {
143                    vec![(key.clone(), value_str)]
144                } else {
145                    vec![(key.clone(), value_str), (upper_key, value_str)]
146                }
147            })
148            .collect()
149    }
150
151    pub fn custom_env_var_names(&self) -> Vec<String> {
152        self.custom.keys().map(|key| key.to_uppercase()).collect()
153    }
154}