Skip to main content

harn_vm/llm/
provider_auth.rs

1use serde::{Deserialize, Serialize};
2
3use crate::llm_config::{self, ProviderDef};
4use crate::value::{VmError, VmValue};
5
6/// Credential resolution state reported by Harn's dispatch authority.
7#[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/// Secret-free provider usability status for native hosts and VM projections.
28#[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
59/// Resolve one provider's usability through the exact credential path used by
60/// dispatch. The returned status never contains secret material.
61pub 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
66/// Resolve all configured and runtime-registered providers in stable name order.
67pub 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
152/// Resolve the provider credential used by dispatch.
153pub 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
256/// Build the canonical no-credentials guidance from the live catalog.
257pub 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}