Skip to main content

omni_dev/jev/
config.rs

1//! Credential and endpoint resolution for the Jev client.
2//!
3//! Reads through an injected [`EnvSource`] —
4//! never `std::env::var` directly (STYLE-0028) — so production resolves via
5//! `settings.json` fallback ([`crate::utils::settings::SettingsEnv`]) while
6//! tests pass a pure `MapEnv` with no process-env mutation.
7
8use anyhow::Result;
9
10use crate::jev::error::JevError;
11use crate::jev::protocol::DEFAULT_MODEL;
12use crate::utils::env::{non_empty_var, EnvSource};
13use crate::utils::secret::Secret;
14use crate::utils::secret_env::secret_var_any;
15
16/// Vendor-primary environment variable / settings key for the Jev API key,
17/// matching TypeSafe's own SDK (mirrors `DATADOG_API_KEY`'s precedent of an
18/// already-exported vendor variable just working).
19pub const TYPESAFE_API_KEY: &str = "TYPESAFE_API_KEY";
20
21/// omni-dev-specific override for the Jev API key, checked after
22/// [`TYPESAFE_API_KEY`].
23pub const OMNI_DEV_JEV_API_KEY: &str = "OMNI_DEV_JEV_API_KEY";
24
25/// Environment variable / settings key overriding the Jev API base URL.
26/// Defaults to [`DEFAULT_BASE_URL`] when unset.
27pub const OMNI_DEV_JEV_BASE_URL: &str = "OMNI_DEV_JEV_BASE_URL";
28
29/// Environment variable / settings key overriding the Jev model.
30///
31/// Deliberately distinct from the global `OMNI_DEV_MODEL`, which
32/// [`JevConfig`] never reads — a user with `OMNI_DEV_MODEL` exported for the
33/// AI backends must not have Jev calls silently redirected to an unrelated
34/// model identifier.
35pub const TYPESAFE_MODEL: &str = "TYPESAFE_MODEL";
36
37/// Default Jev API base URL.
38pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
39
40/// Resolved configuration for a [`crate::jev::client::JevClient`].
41#[derive(Clone)]
42pub struct JevConfig {
43    /// The Jev API key (redacted in `Debug` output).
44    pub api_key: Secret,
45    /// The Jev API base URL, with any trailing slash trimmed.
46    pub base_url: String,
47    /// The Jev model to use, e.g. [`DEFAULT_MODEL`].
48    pub model: String,
49}
50
51impl std::fmt::Debug for JevConfig {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("JevConfig")
54            .field("api_key", &self.api_key)
55            .field("base_url", &self.base_url)
56            .field("model", &self.model)
57            .finish()
58    }
59}
60
61impl JevConfig {
62    /// Resolves configuration from the process environment (with a
63    /// `settings.json` fallback).
64    pub fn from_env() -> Result<Self> {
65        Self::from_env_with(&crate::utils::settings::SettingsEnv::load())
66    }
67
68    /// [`from_env`](Self::from_env) over an injected [`EnvSource`].
69    ///
70    /// Tests pass a pure `MapEnv` to exercise precedence without mutating
71    /// the process environment (STYLE-0028).
72    pub(crate) fn from_env_with(env: &impl EnvSource) -> Result<Self> {
73        let api_key = secret_var_any(env, &[TYPESAFE_API_KEY, OMNI_DEV_JEV_API_KEY])?
74            .ok_or(JevError::CredentialsNotFound)?;
75
76        let base_url = non_empty_var(env, OMNI_DEV_JEV_BASE_URL)
77            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
78            .trim_end_matches('/')
79            .to_string();
80
81        let model = non_empty_var(env, TYPESAFE_MODEL).unwrap_or_else(|| DEFAULT_MODEL.to_string());
82
83        Ok(Self {
84            api_key,
85            base_url,
86            model,
87        })
88    }
89}
90
91#[cfg(test)]
92#[allow(clippy::unwrap_used, clippy::expect_used)]
93mod tests {
94    use super::*;
95    use crate::test_support::env::MapEnv;
96
97    #[test]
98    fn typesafe_api_key_wins_over_omni_dev_override() {
99        let env = MapEnv::new()
100            .with(TYPESAFE_API_KEY, "typesafe-key")
101            .with(OMNI_DEV_JEV_API_KEY, "omni-dev-key");
102        let config = JevConfig::from_env_with(&env).unwrap();
103        assert_eq!(config.api_key.expose_secret(), "typesafe-key");
104    }
105
106    #[test]
107    fn omni_dev_override_used_when_typesafe_key_unset() {
108        let env = MapEnv::new().with(OMNI_DEV_JEV_API_KEY, "omni-dev-key");
109        let config = JevConfig::from_env_with(&env).unwrap();
110        assert_eq!(config.api_key.expose_secret(), "omni-dev-key");
111    }
112
113    #[test]
114    fn empty_typesafe_key_counts_as_unset() {
115        let env = MapEnv::new()
116            .with(TYPESAFE_API_KEY, "")
117            .with(OMNI_DEV_JEV_API_KEY, "omni-dev-key");
118        let config = JevConfig::from_env_with(&env).unwrap();
119        assert_eq!(config.api_key.expose_secret(), "omni-dev-key");
120    }
121
122    #[test]
123    fn missing_api_key_is_credentials_not_found() {
124        let env = MapEnv::new();
125        let err = JevConfig::from_env_with(&env).unwrap_err();
126        assert!(matches!(
127            err.downcast_ref::<JevError>(),
128            Some(JevError::CredentialsNotFound)
129        ));
130    }
131
132    #[test]
133    fn base_url_defaults_when_unset() {
134        let env = MapEnv::new().with(TYPESAFE_API_KEY, "k");
135        let config = JevConfig::from_env_with(&env).unwrap();
136        assert_eq!(config.base_url, DEFAULT_BASE_URL);
137    }
138
139    #[test]
140    fn base_url_override_trims_trailing_slash() {
141        let env = MapEnv::new()
142            .with(TYPESAFE_API_KEY, "k")
143            .with(OMNI_DEV_JEV_BASE_URL, "http://proxy.example:8080/");
144        let config = JevConfig::from_env_with(&env).unwrap();
145        assert_eq!(config.base_url, "http://proxy.example:8080");
146    }
147
148    #[test]
149    fn model_defaults_when_unset() {
150        let env = MapEnv::new().with(TYPESAFE_API_KEY, "k");
151        let config = JevConfig::from_env_with(&env).unwrap();
152        assert_eq!(config.model, DEFAULT_MODEL);
153    }
154
155    #[test]
156    fn model_override_resolves() {
157        let env = MapEnv::new()
158            .with(TYPESAFE_API_KEY, "k")
159            .with(TYPESAFE_MODEL, "jev-1.13.0");
160        let config = JevConfig::from_env_with(&env).unwrap();
161        assert_eq!(config.model, "jev-1.13.0");
162    }
163
164    #[test]
165    fn omni_dev_model_is_ignored() {
166        let env = MapEnv::new()
167            .with(TYPESAFE_API_KEY, "k")
168            .with("OMNI_DEV_MODEL", "claude-opus-5");
169        let config = JevConfig::from_env_with(&env).unwrap();
170        assert_eq!(config.model, DEFAULT_MODEL);
171    }
172
173    #[test]
174    fn debug_redacts_api_key() {
175        let env = MapEnv::new().with(TYPESAFE_API_KEY, "sekret-value");
176        let config = JevConfig::from_env_with(&env).unwrap();
177        let debug = format!("{config:?}");
178        assert!(!debug.contains("sekret-value"));
179        assert!(debug.contains("api_key: <redacted>"));
180    }
181
182    #[test]
183    fn api_key_can_come_from_a_file() {
184        let (_dir, path) = crate::test_support::env::secret_file("from-file\n");
185        let env = MapEnv::new().with("OMNI_DEV_JEV_API_KEY_FILE", &path);
186        let config = JevConfig::from_env_with(&env).unwrap();
187        assert_eq!(config.api_key.expose_secret(), "from-file");
188    }
189}