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 session_auth_env(name: &str) -> Option<String> {
222 crate::stdlib::process::session_env_var(name)
223 .ok()
224 .flatten()
225 .filter(|value| !value.is_empty())
226}
227
228fn probe_api_key(
229 definition: Option<&ProviderDef>,
230) -> Result<Option<String>, ProviderCredentialError> {
231 let Some(definition) = definition else {
232 return session_auth_env("ANTHROPIC_API_KEY")
233 .map(Some)
234 .ok_or(ProviderCredentialError::Missing);
235 };
236 match &definition.auth_env {
237 llm_config::AuthEnv::None => Ok(None),
238 llm_config::AuthEnv::Single(env) => {
239 let raw = session_auth_env(env).ok_or(ProviderCredentialError::Missing)?;
240 resolve_auth_env_value(env, &raw)
241 .map_err(ProviderCredentialError::Resolution)
242 .map(Some)
243 }
244 llm_config::AuthEnv::Multiple(envs) => {
245 for env in envs {
246 let Some(raw) = session_auth_env(env) else {
247 continue;
248 };
249 return resolve_auth_env_value(env, &raw)
250 .map_err(ProviderCredentialError::Resolution)
251 .map(Some);
252 }
253 Err(ProviderCredentialError::Missing)
254 }
255 }
256}
257
258fn missing_key_error(message: String) -> VmError {
259 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message)))
260}
261
262fn resolve_auth_env_value(env_name: &str, raw: &str) -> Result<String, VmError> {
263 match crate::secrets::resolve_secret_ref_to_string(raw) {
264 Ok(Some(secret)) => Ok(secret),
265 Ok(None) => Ok(raw.to_string()),
266 Err(error) => Err(missing_key_error(format!(
267 "Failed to resolve API key secret reference from {env_name}: {error}"
268 ))),
269 }
270}
271
272pub fn no_credentials_message() -> String {
274 let mut envs = Vec::new();
275 for name in llm_config::provider_names() {
276 if let Some(definition) = llm_config::provider_config(&name) {
277 if definition.auth_style == "none" {
278 continue;
279 }
280 for env in llm_config::auth_env_names(&definition.auth_env) {
281 if !envs.contains(&env) {
282 envs.push(env);
283 }
284 }
285 }
286 }
287 envs.sort();
288 envs.dedup();
289 let env_list = if envs.is_empty() {
290 "(no providers declared)".to_string()
291 } else {
292 envs.join(", ")
293 };
294 format!(
295 "No LLM providers configured. Set one of these env vars to an API key or \
296 harn-secret://namespace/name reference: {env_list} (or run a local Ollama). \
297 For diagnostics: `harn doctor`. For a recommended setup: `harn models recommend` \
298 (when available)."
299 )
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 struct ScopedEnv {
307 name: &'static str,
308 previous: Option<String>,
309 }
310
311 impl ScopedEnv {
312 fn set(name: &'static str, value: &str) -> Self {
313 let previous = std::env::var(name).ok();
314 unsafe { std::env::set_var(name, value) };
315 Self { name, previous }
316 }
317
318 fn unset(name: &'static str) -> Self {
319 let previous = std::env::var(name).ok();
320 unsafe { std::env::remove_var(name) };
321 Self { name, previous }
322 }
323 }
324
325 impl Drop for ScopedEnv {
326 fn drop(&mut self) {
327 unsafe {
328 match &self.previous {
329 Some(value) => std::env::set_var(self.name, value),
330 None => std::env::remove_var(self.name),
331 }
332 }
333 }
334 }
335
336 #[test]
337 fn typed_status_covers_each_credential_resolution_class() {
338 let _guard = crate::llm::env_guard();
339 let _anthropic = ScopedEnv::unset("ANTHROPIC_API_KEY");
340 let _azure_key = ScopedEnv::unset("AZURE_OPENAI_API_KEY");
341 let _azure_token = ScopedEnv::unset("AZURE_OPENAI_AD_TOKEN");
342 let _azure_bearer = ScopedEnv::unset("AZURE_OPENAI_BEARER_TOKEN");
343
344 assert_eq!(
345 provider_auth_status("anthropic").credential_status,
346 ProviderCredentialStatus::Missing
347 );
348 assert_eq!(
349 provider_auth_status("ollama").credential_status,
350 ProviderCredentialStatus::NotRequired
351 );
352 assert_eq!(
353 provider_auth_status("bedrock").credential_status,
354 ProviderCredentialStatus::Deferred
355 );
356 assert_eq!(
357 provider_auth_status("vertex").credential_status,
358 ProviderCredentialStatus::Deferred
359 );
360 assert_eq!(resolve_api_key("bedrock").unwrap(), "");
361 assert_eq!(resolve_api_key("vertex").unwrap(), "");
362 assert!(resolve_api_key("azure_openai").is_err());
363 }
364
365 #[test]
366 fn status_and_dispatch_resolve_the_same_secret_reference_without_caching() {
367 let _guard = crate::llm::env_guard();
368 let _providers = ScopedEnv::set("HARN_SECRET_PROVIDERS", "env");
369 let _reference = ScopedEnv::set(
370 "ANTHROPIC_API_KEY",
371 "harn-secret://provider/anthropic-api-key",
372 );
373 let secret = ScopedEnv::set("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY", "sk-from-ref");
374
375 assert_eq!(resolve_api_key("anthropic").unwrap(), "sk-from-ref");
376 assert_eq!(
377 provider_auth_status("anthropic").credential_status,
378 ProviderCredentialStatus::Ok
379 );
380
381 drop(secret);
382 let _missing = ScopedEnv::unset("HARN_SECRET_PROVIDER_ANTHROPIC_API_KEY");
383 assert_eq!(
384 provider_auth_status("anthropic").credential_status,
385 ProviderCredentialStatus::Missing
386 );
387 let error = resolve_api_key("anthropic").unwrap_err();
388 let message = match error {
389 VmError::Thrown(VmValue::String(message)) => message.to_string(),
390 other => format!("{other:?}"),
391 };
392 assert!(
393 message.contains("Failed to resolve API key secret reference from ANTHROPIC_API_KEY")
394 );
395 assert!(message.contains("provider/anthropic-api-key"));
396 assert!(!message.contains("sk-from-ref"));
397 }
398
399 struct ScopedEnvironment;
403
404 impl ScopedEnvironment {
405 fn install(environment: crate::security::SessionEnvironment) -> Self {
406 crate::stdlib::process::set_session_environment(Some(environment));
407 Self
408 }
409 }
410
411 impl Drop for ScopedEnvironment {
412 fn drop(&mut self) {
413 crate::stdlib::process::set_session_environment(None);
414 }
415 }
416
417 #[test]
418 fn isolated_policy_hides_the_launcher_key_from_dispatch() {
419 let _guard = crate::llm::env_guard();
423 let _key = ScopedEnv::set("ANTHROPIC_API_KEY", "sk-launcher");
424
425 assert_eq!(
426 provider_auth_status("anthropic").credential_status,
427 ProviderCredentialStatus::Ok,
428 "outside a session, bootstrap reads still use the process env"
429 );
430
431 let _environment =
432 ScopedEnvironment::install(crate::security::SessionEnvironment::isolated());
433 assert_eq!(
434 provider_auth_status("anthropic").credential_status,
435 ProviderCredentialStatus::Missing,
436 "isolated must close the in-process credential path, not just subprocess env"
437 );
438 let message = match resolve_api_key("anthropic").unwrap_err() {
439 VmError::Thrown(VmValue::String(message)) => message.to_string(),
440 other => format!("{other:?}"),
441 };
442 assert!(message.contains("Missing API key"));
443 assert!(!message.contains("sk-launcher"), "error leaked the key");
444 }
445
446 #[test]
447 fn lane_grant_reaches_harns_own_dispatch() {
448 use crate::security::{
453 EnvironmentPolicyKind, GrantSourceSpec, GrantSpec, SessionEnvironment,
454 };
455
456 let _guard = crate::llm::env_guard();
457 let _absent = ScopedEnv::unset("ANTHROPIC_API_KEY");
458 let _source = ScopedEnv::set("LAUNCHER_ANTHROPIC_SECRET", "sk-granted");
459
460 let granted = SessionEnvironment::launch(
461 EnvironmentPolicyKind::Granted,
462 vec![GrantSpec {
463 name: "anthropic".to_string(),
464 source: GrantSourceSpec::Env {
465 var: "LAUNCHER_ANTHROPIC_SECRET".to_string(),
466 },
467 expose_as_env: Some("ANTHROPIC_API_KEY".to_string()),
468 for_command: None,
469 }],
470 &|name| std::env::var(name).ok(),
471 )
472 .expect("granted policy launch");
473 let _environment = ScopedEnvironment::install(granted);
474
475 assert_eq!(
476 provider_auth_status("anthropic").credential_status,
477 ProviderCredentialStatus::Ok
478 );
479 assert_eq!(resolve_api_key("anthropic").unwrap(), "sk-granted");
480 }
481
482 #[test]
483 fn an_empty_auth_variable_is_a_missing_credential() {
484 let _guard = crate::llm::env_guard();
489 let _blank = ScopedEnv::set("ANTHROPIC_API_KEY", "");
490 assert_eq!(
491 provider_auth_status("anthropic").credential_status,
492 ProviderCredentialStatus::Missing
493 );
494 assert!(resolve_api_key("anthropic").is_err());
495 }
496
497 #[test]
498 fn status_serialization_is_stable_and_secret_free() {
499 let status = ProviderAuthStatus {
500 name: "example".to_string(),
501 available: true,
502 credential_status: ProviderCredentialStatus::Deferred,
503 };
504 assert_eq!(
505 serde_json::to_value(status).unwrap(),
506 serde_json::json!({
507 "name": "example",
508 "available": true,
509 "credential_status": "deferred",
510 })
511 );
512 }
513
514 #[test]
515 fn available_provider_names_uses_dispatch_semantics() {
516 let _guard = crate::llm::env_guard();
517 let _vertex_token = ScopedEnv::unset("VERTEX_AI_ACCESS_TOKEN");
518 let _google_token = ScopedEnv::unset("GOOGLE_OAUTH_ACCESS_TOKEN");
519 let _google_credentials = ScopedEnv::unset("GOOGLE_APPLICATION_CREDENTIALS");
520
521 let available = available_provider_names();
522 assert!(available.iter().any(|name| name == "bedrock"));
523 assert!(available.iter().any(|name| name == "vertex"));
524 }
525}