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, database URL, and
4//! AI-provider keys. [`resolve_primary`] picks the default provider from an
5//! explicit flag or the first present key by [`PROVIDER_PRIORITY`].
6
7use anyhow::{Result, bail};
8use serde::{Deserialize, Serialize};
9use systemprompt_identifiers::ProviderId;
10
11use super::super::SetupArgs;
12
13pub(super) const STANDARD_PROVIDERS: [&str; 3] = ["gemini", "anthropic", "openai"];
14
15// Default-provider precedence when no --default-provider flag is given: the
16// first provider in this order whose key was supplied wins.
17const PROVIDER_PRIORITY: [&str; 3] = ["anthropic", "openai", "gemini"];
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct SecretsData {
21    pub oauth_at_rest_pepper: String,
22
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub database_url: Option<String>,
25
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub gemini: Option<String>,
28
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub anthropic: Option<String>,
31
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub openai: Option<String>,
34
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub github: Option<String>,
37}
38
39impl SecretsData {
40    pub(crate) const fn has_ai_provider(&self) -> bool {
41        self.gemini.is_some() || self.anthropic.is_some() || self.openai.is_some()
42    }
43
44    fn key_for(&self, provider: &str) -> Option<&String> {
45        match provider {
46            "gemini" => self.gemini.as_ref(),
47            "anthropic" => self.anthropic.as_ref(),
48            "openai" => self.openai.as_ref(),
49            _ => None,
50        }
51    }
52
53    pub(crate) fn present_providers(&self) -> Vec<&'static str> {
54        STANDARD_PROVIDERS
55            .into_iter()
56            .filter(|p| self.key_for(p).is_some())
57            .collect()
58    }
59
60    pub(crate) fn summary(&self) -> String {
61        let mut keys = Vec::new();
62        if self.gemini.is_some() {
63            keys.push("Gemini");
64        }
65        if self.anthropic.is_some() {
66            keys.push("Anthropic");
67        }
68        if self.openai.is_some() {
69            keys.push("OpenAI");
70        }
71        if self.github.is_some() {
72            keys.push("GitHub");
73        }
74
75        if keys.is_empty() {
76            "None".to_owned()
77        } else {
78            keys.join(", ")
79        }
80    }
81}
82
83fn first_present_by_priority(secrets: &SecretsData) -> Option<ProviderId> {
84    PROVIDER_PRIORITY
85        .into_iter()
86        .find(|p| secrets.key_for(p).is_some())
87        .map(ProviderId::new)
88}
89
90/// An explicit, key-backed `--default-provider` flag wins; otherwise the first
91/// present key by [`PROVIDER_PRIORITY`]. The flag's absence is never fatal —
92/// `validate_secrets` already guarantees at least one key is present.
93pub(super) fn resolve_primary(
94    args: &SetupArgs,
95    secrets: &SecretsData,
96) -> Result<Option<ProviderId>> {
97    let Some(name) = args.default_provider.as_deref().map(str::trim) else {
98        return Ok(first_present_by_priority(secrets));
99    };
100    if !STANDARD_PROVIDERS.contains(&name) {
101        bail!("--default-provider must be one of: gemini, anthropic, openai (got '{name}')");
102    }
103    if secrets.key_for(name).is_none() {
104        bail!(
105            "--default-provider '{name}' has no API key; pass --{name}-key or drop \
106             --default-provider"
107        );
108    }
109    Ok(Some(ProviderId::new(name)))
110}