1use serde::{Deserialize, Serialize};
2
3use crate::llm_config::{self, ProviderDef};
4use crate::value::{VmError, VmValue};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ProviderCredentialStatus {
10 Ok,
11 Missing,
12 NotRequired,
13 Deferred,
14}
15
16impl ProviderCredentialStatus {
17 pub const fn as_str(self) -> &'static str {
18 match self {
19 Self::Ok => "ok",
20 Self::Missing => "missing",
21 Self::NotRequired => "not_required",
22 Self::Deferred => "deferred",
23 }
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ProviderAuthStatus {
30 pub name: String,
31 pub available: bool,
32 pub credential_status: ProviderCredentialStatus,
33}
34
35#[derive(Debug)]
36pub(crate) struct ResolvedProviderAuth {
37 pub status: ProviderAuthStatus,
38 credential: ResolvedProviderCredential,
39}
40
41impl ResolvedProviderAuth {
42 pub(crate) fn into_api_key(self) -> Option<String> {
43 match self.credential {
44 ResolvedProviderCredential::Key(api_key) => Some(api_key),
45 _ => None,
46 }
47 }
48}
49
50#[derive(Debug)]
51enum ResolvedProviderCredential {
52 Key(String),
53 Missing,
54 NotRequired,
55 Deferred,
56 ResolutionError(VmError),
57}
58
59pub fn provider_auth_status(provider: &str) -> ProviderAuthStatus {
62 let definition = llm_config::provider_config(provider);
63 resolve_provider_auth_with_definition(provider, definition.as_ref()).status
64}
65
66pub fn provider_auth_statuses() -> Vec<ProviderAuthStatus> {
68 super::provider::register_default_providers();
69 let mut names: std::collections::BTreeSet<String> =
70 llm_config::provider_names().into_iter().collect();
71 names.extend(super::provider::registered_provider_names());
72 names
73 .into_iter()
74 .map(|name| provider_auth_status(&name))
75 .collect()
76}
77
78pub fn available_provider_names() -> Vec<String> {
79 llm_config::provider_names()
80 .into_iter()
81 .filter(|provider| provider_auth_status(provider).available)
82 .collect()
83}
84
85pub(crate) fn provider_auth_status_with_definition(
86 provider: &str,
87 definition: &ProviderDef,
88) -> ProviderAuthStatus {
89 resolve_provider_auth_with_definition(provider, Some(definition)).status
90}
91
92pub(crate) fn resolve_provider_auth(provider: &str) -> ResolvedProviderAuth {
93 let definition = llm_config::provider_config(provider);
94 resolve_provider_auth_with_definition(provider, definition.as_ref())
95}
96
97fn resolve_provider_auth_with_definition(
98 provider: &str,
99 definition: Option<&ProviderDef>,
100) -> ResolvedProviderAuth {
101 let name = provider.to_string();
102 let credential = if provider == "mock"
103 || provider == "fake"
104 || super::mock::cli_llm_mock_replay_active()
105 || super::mock::builtin_llm_mock_active()
106 {
107 ResolvedProviderCredential::NotRequired
108 } else if let Some(definition) = definition {
109 if definition.is_credential_resolution_platform_managed() {
110 ResolvedProviderCredential::Deferred
111 } else if definition.auth_style == "none"
112 || matches!(definition.auth_env, llm_config::AuthEnv::None)
113 {
114 ResolvedProviderCredential::NotRequired
115 } else {
116 resolved_credential_from_probe(probe_api_key(Some(definition)))
117 }
118 } else {
119 resolved_credential_from_probe(probe_api_key(None))
120 };
121 let (available, credential_status) = match &credential {
122 ResolvedProviderCredential::Key(_) => (true, ProviderCredentialStatus::Ok),
123 ResolvedProviderCredential::Missing | ResolvedProviderCredential::ResolutionError(_) => {
124 (false, ProviderCredentialStatus::Missing)
125 }
126 ResolvedProviderCredential::NotRequired => (true, ProviderCredentialStatus::NotRequired),
127 ResolvedProviderCredential::Deferred => (true, ProviderCredentialStatus::Deferred),
128 };
129 ResolvedProviderAuth {
130 status: ProviderAuthStatus {
131 name,
132 available,
133 credential_status,
134 },
135 credential,
136 }
137}
138
139fn resolved_credential_from_probe(
140 result: Result<Option<String>, ProviderCredentialError>,
141) -> ResolvedProviderCredential {
142 match result {
143 Ok(Some(api_key)) => ResolvedProviderCredential::Key(api_key),
144 Ok(None) => ResolvedProviderCredential::NotRequired,
145 Err(ProviderCredentialError::Missing) => ResolvedProviderCredential::Missing,
146 Err(ProviderCredentialError::Resolution(error)) => {
147 ResolvedProviderCredential::ResolutionError(error)
148 }
149 }
150}
151
152pub fn resolve_api_key(provider: &str) -> Result<String, VmError> {
154 let definition = llm_config::provider_config(provider);
155 resolve_api_key_with_definition(provider, definition.as_ref())
156}
157
158fn resolve_api_key_with_definition(
159 provider: &str,
160 definition: Option<&ProviderDef>,
161) -> Result<String, VmError> {
162 let selection_hint = {
163 let config_path = llm_config::loaded_config_path()
164 .map(|path| path.display().to_string())
165 .unwrap_or_else(|| "<built-in defaults>".to_string());
166 format!(
167 " (provider '{provider}' selected via LLM_PROVIDER / llm.toml @ {config_path}; \
168 set HARN_LLM_PROVIDER=mock or LLM_PROVIDER=mock for offline use)"
169 )
170 };
171
172 match resolve_provider_auth_with_definition(provider, definition).credential {
173 ResolvedProviderCredential::Key(api_key) => Ok(api_key),
174 ResolvedProviderCredential::NotRequired | ResolvedProviderCredential::Deferred => {
175 Ok(String::new())
176 }
177 ResolvedProviderCredential::ResolutionError(error) => Err(error),
178 ResolvedProviderCredential::Missing => {
179 if let Some(definition) = definition {
180 let aggregate_hint = no_credentials_message();
181 let requirement = match &definition.auth_env {
182 llm_config::AuthEnv::Single(env) => {
183 format!("set {env} environment variable")
184 }
185 llm_config::AuthEnv::Multiple(envs) => {
186 format!("set one of {} environment variables", envs.join(", "))
187 }
188 llm_config::AuthEnv::None => return Ok(String::new()),
189 };
190 Err(missing_key_error(format!(
191 "Missing API key: {requirement}{selection_hint}\n{aggregate_hint}"
192 )))
193 } else {
194 let aggregate_hint = no_credentials_message();
195 Err(missing_key_error(format!(
196 "Missing API key: set ANTHROPIC_API_KEY environment variable{selection_hint}\n{aggregate_hint}"
197 )))
198 }
199 }
200 }
201}
202
203#[derive(Debug)]
204enum ProviderCredentialError {
205 Missing,
206 Resolution(VmError),
207}
208
209fn probe_api_key(
210 definition: Option<&ProviderDef>,
211) -> Result<Option<String>, ProviderCredentialError> {
212 let Some(definition) = definition else {
213 return std::env::var("ANTHROPIC_API_KEY")
214 .map(Some)
215 .map_err(|_| ProviderCredentialError::Missing);
216 };
217 match &definition.auth_env {
218 llm_config::AuthEnv::None => Ok(None),
219 llm_config::AuthEnv::Single(env) => {
220 let raw = std::env::var(env).map_err(|_| ProviderCredentialError::Missing)?;
221 resolve_auth_env_value(env, &raw)
222 .map_err(ProviderCredentialError::Resolution)
223 .map(Some)
224 }
225 llm_config::AuthEnv::Multiple(envs) => {
226 for env in envs {
227 let Ok(raw) = std::env::var(env) else {
228 continue;
229 };
230 if raw.is_empty() {
231 continue;
232 }
233 return resolve_auth_env_value(env, &raw)
234 .map_err(ProviderCredentialError::Resolution)
235 .map(Some);
236 }
237 Err(ProviderCredentialError::Missing)
238 }
239 }
240}
241
242fn missing_key_error(message: String) -> VmError {
243 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message)))
244}
245
246fn resolve_auth_env_value(env_name: &str, raw: &str) -> Result<String, VmError> {
247 match crate::secrets::resolve_secret_ref_to_string(raw) {
248 Ok(Some(secret)) => Ok(secret),
249 Ok(None) => Ok(raw.to_string()),
250 Err(error) => Err(missing_key_error(format!(
251 "Failed to resolve API key secret reference from {env_name}: {error}"
252 ))),
253 }
254}
255
256pub fn no_credentials_message() -> String {
258 let mut envs = Vec::new();
259 for name in llm_config::provider_names() {
260 if let Some(definition) = llm_config::provider_config(&name) {
261 if definition.auth_style == "none" {
262 continue;
263 }
264 for env in llm_config::auth_env_names(&definition.auth_env) {
265 if !envs.contains(&env) {
266 envs.push(env);
267 }
268 }
269 }
270 }
271 envs.sort();
272 envs.dedup();
273 let env_list = if envs.is_empty() {
274 "(no providers declared)".to_string()
275 } else {
276 envs.join(", ")
277 };
278 format!(
279 "No LLM providers configured. Set one of these env vars to an API key or \
280 harn-secret://namespace/name reference: {env_list} (or run a local Ollama). \
281 For diagnostics: `harn doctor`. For a recommended setup: `harn models recommend` \
282 (when available)."
283 )
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 struct ScopedEnv {
291 name: &'static str,
292 previous: Option<String>,
293 }
294
295 impl ScopedEnv {
296 fn set(name: &'static str, value: &str) -> Self {
297 let previous = std::env::var(name).ok();
298 unsafe { std::env::set_var(name, value) };
299 Self { name, previous }
300 }
301
302 fn unset(name: &'static str) -> Self {
303 let previous = std::env::var(name).ok();
304 unsafe { std::env::remove_var(name) };
305 Self { name, previous }
306 }
307 }
308
309 impl Drop for ScopedEnv {
310 fn drop(&mut self) {
311 unsafe {
312 match &self.previous {
313 Some(value) => std::env::set_var(self.name, value),
314 None => std::env::remove_var(self.name),
315 }
316 }
317 }
318 }
319
320 #[test]
321 fn typed_status_covers_each_credential_resolution_class() {
322 let _guard = crate::llm::env_guard();
323 let _anthropic = ScopedEnv::unset("ANTHROPIC_API_KEY");
324 let _azure_key = ScopedEnv::unset("AZURE_OPENAI_API_KEY");
325 let _azure_token = ScopedEnv::unset("AZURE_OPENAI_AD_TOKEN");
326 let _azure_bearer = ScopedEnv::unset("AZURE_OPENAI_BEARER_TOKEN");
327
328 assert_eq!(
329 provider_auth_status("anthropic").credential_status,
330 ProviderCredentialStatus::Missing
331 );
332 assert_eq!(
333 provider_auth_status("ollama").credential_status,
334 ProviderCredentialStatus::NotRequired
335 );
336 assert_eq!(
337 provider_auth_status("bedrock").credential_status,
338 ProviderCredentialStatus::Deferred
339 );
340 assert_eq!(
341 provider_auth_status("vertex").credential_status,
342 ProviderCredentialStatus::Deferred
343 );
344 assert_eq!(resolve_api_key("bedrock").unwrap(), "");
345 assert_eq!(resolve_api_key("vertex").unwrap(), "");
346 assert!(resolve_api_key("azure_openai").is_err());
347 }
348
349 #[test]
350 fn status_and_dispatch_resolve_the_same_secret_reference_without_caching() {
351 let _guard = crate::llm::env_guard();
352 let _providers = ScopedEnv::set("HARN_SECRET_PROVIDERS", "env");
353 let _reference = ScopedEnv::set(
354 "ANTHROPIC_API_KEY",
355 "harn-secret://provider/anthropic-api-key",
356 );
357 let secret = ScopedEnv::set("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY", "sk-from-ref");
358
359 assert_eq!(resolve_api_key("anthropic").unwrap(), "sk-from-ref");
360 assert_eq!(
361 provider_auth_status("anthropic").credential_status,
362 ProviderCredentialStatus::Ok
363 );
364
365 drop(secret);
366 let _missing = ScopedEnv::unset("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY");
367 assert_eq!(
368 provider_auth_status("anthropic").credential_status,
369 ProviderCredentialStatus::Missing
370 );
371 let error = resolve_api_key("anthropic").unwrap_err();
372 let message = match error {
373 VmError::Thrown(VmValue::String(message)) => message.to_string(),
374 other => format!("{other:?}"),
375 };
376 assert!(
377 message.contains("Failed to resolve API key secret reference from ANTHROPIC_API_KEY")
378 );
379 assert!(message.contains("provider/anthropic-api-key"));
380 assert!(!message.contains("sk-from-ref"));
381 }
382
383 #[test]
384 fn status_serialization_is_stable_and_secret_free() {
385 let status = ProviderAuthStatus {
386 name: "example".to_string(),
387 available: true,
388 credential_status: ProviderCredentialStatus::Deferred,
389 };
390 assert_eq!(
391 serde_json::to_value(status).unwrap(),
392 serde_json::json!({
393 "name": "example",
394 "available": true,
395 "credential_status": "deferred",
396 })
397 );
398 }
399
400 #[test]
401 fn available_provider_names_uses_dispatch_semantics() {
402 let _guard = crate::llm::env_guard();
403 let _vertex_token = ScopedEnv::unset("VERTEX_AI_ACCESS_TOKEN");
404 let _google_token = ScopedEnv::unset("GOOGLE_OAUTH_ACCESS_TOKEN");
405 let _google_credentials = ScopedEnv::unset("GOOGLE_APPLICATION_CREDENTIALS");
406
407 let available = available_provider_names();
408 assert!(available.iter().any(|name| name == "bedrock"));
409 assert!(available.iter().any(|name| name == "vertex"));
410 }
411}