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