Skip to main content

systemprompt_cli/commands/admin/setup/secrets/
data.rs

1//! Secrets data model and default-provider resolution.
2//!
3//! [`SecretsData`] holds the generated OAuth at-rest pepper, encryption master
4//! key, database URL, and AI-provider keys. [`resolve_primary`] picks the
5//! default provider from an explicit flag or the first present key by
6//! [`PROVIDER_PRIORITY`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use anyhow::{Result, bail};
12use serde::{Deserialize, Serialize};
13use systemprompt_identifiers::ProviderId;
14
15use super::super::SetupArgs;
16
17pub(super) const STANDARD_PROVIDERS: [&str; 3] = ["gemini", "anthropic", "openai"];
18
19const PROVIDER_PRIORITY: [&str; 3] = ["anthropic", "openai", "gemini"];
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct SecretsData {
23    pub oauth_at_rest_pepper: String,
24
25    pub encryption_master_key: String,
26
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub manifest_signing_secret_seed: Option<String>,
29
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub signing_key_pem: Option<String>,
32
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub database_url: Option<String>,
35
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub gemini: Option<String>,
38
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub anthropic: Option<String>,
41
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub openai: Option<String>,
44
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub github: Option<String>,
47}
48
49impl SecretsData {
50    pub(crate) const fn has_ai_provider(&self) -> bool {
51        self.gemini.is_some() || self.anthropic.is_some() || self.openai.is_some()
52    }
53
54    fn key_for(&self, provider: &str) -> Option<&String> {
55        match provider {
56            "gemini" => self.gemini.as_ref(),
57            "anthropic" => self.anthropic.as_ref(),
58            "openai" => self.openai.as_ref(),
59            _ => None,
60        }
61    }
62
63    pub(crate) fn present_providers(&self) -> Vec<&'static str> {
64        STANDARD_PROVIDERS
65            .into_iter()
66            .filter(|p| self.key_for(p).is_some())
67            .collect()
68    }
69
70    pub(crate) fn summary(&self) -> String {
71        let mut keys = Vec::new();
72        if self.gemini.is_some() {
73            keys.push("Gemini");
74        }
75        if self.anthropic.is_some() {
76            keys.push("Anthropic");
77        }
78        if self.openai.is_some() {
79            keys.push("OpenAI");
80        }
81        if self.github.is_some() {
82            keys.push("GitHub");
83        }
84
85        if keys.is_empty() {
86            "None".to_owned()
87        } else {
88            keys.join(", ")
89        }
90    }
91}
92
93fn first_present_by_priority(secrets: &SecretsData) -> Option<ProviderId> {
94    PROVIDER_PRIORITY
95        .into_iter()
96        .find(|p| secrets.key_for(p).is_some())
97        .map(ProviderId::new)
98}
99
100pub(super) fn resolve_primary(
101    args: &SetupArgs,
102    secrets: &SecretsData,
103) -> Result<Option<ProviderId>> {
104    let Some(name) = args.default_provider.as_deref().map(str::trim) else {
105        return Ok(first_present_by_priority(secrets));
106    };
107    if !STANDARD_PROVIDERS.contains(&name) {
108        bail!("--default-provider must be one of: gemini, anthropic, openai (got '{name}')");
109    }
110    if secrets.key_for(name).is_none() {
111        bail!(
112            "--default-provider '{name}' has no API key; pass --{name}-key or drop \
113             --default-provider"
114        );
115    }
116    Ok(Some(ProviderId::new(name)))
117}