1use 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
16pub const TYPESAFE_API_KEY: &str = "TYPESAFE_API_KEY";
20
21pub const OMNI_DEV_JEV_API_KEY: &str = "OMNI_DEV_JEV_API_KEY";
24
25pub const OMNI_DEV_JEV_BASE_URL: &str = "OMNI_DEV_JEV_BASE_URL";
28
29pub const TYPESAFE_MODEL: &str = "TYPESAFE_MODEL";
36
37pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
39
40#[derive(Clone)]
42pub struct JevConfig {
43 pub api_key: Secret,
45 pub base_url: String,
47 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 pub fn from_env() -> Result<Self> {
65 Self::from_env_with(&crate::utils::settings::SettingsEnv::load())
66 }
67
68 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}