1use crate::ProviderId;
2use crate::config::{ProviderConfig, canonical_provider_id, model_can_run_publicly};
3use anyhow::{Context, Result};
4use directories::BaseDirs;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13struct CredentialsFile {
14 #[serde(default)]
15 ignored_providers: Vec<String>,
16 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
17 project_accounts: HashMap<String, HashMap<String, String>>,
18 #[serde(flatten)]
19 providers: HashMap<String, ProviderCredentials>,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23struct ProviderCredentials {
24 #[serde(default, skip_serializing_if = "String::is_empty")]
25 api_key: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 commandcode: Option<CommandCodeCredentialMetadata>,
28 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29 accounts: BTreeMap<String, CredentialAccount>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 default_account: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct CredentialAccount {
37 api_key: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 label: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 commandcode: Option<CommandCodeCredentialMetadata>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 authenticated_at: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 oauth_api_kind: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 oauth_refresh_token: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 oauth_expires_at: Option<i64>,
52}
53
54pub const XAI_GROK_CLI_OAUTH_KIND: &str = "xai-grok-cli";
56
57pub fn is_model_usable_oauth_kind(kind: &str) -> bool {
59 kind == XAI_GROK_CLI_OAUTH_KIND
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CommandCodeCredentialMetadata {
65 pub user_id: String,
66 pub user_name: String,
67 pub key_name: String,
68 pub authenticated_at: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CredentialAccountInfo {
74 pub account_id: String,
75 pub label: String,
76 pub is_default: bool,
77 pub is_project_selected: bool,
78 pub commandcode: Option<CommandCodeCredentialMetadata>,
79}
80
81const DEFAULT_ACCOUNT_ID: &str = "default";
82
83impl ProviderCredentials {
84 fn default_account_id(&self) -> Option<String> {
85 self.default_account
86 .clone()
87 .or_else(|| self.accounts.keys().next().cloned())
88 .or_else(|| (!self.api_key.is_empty()).then(|| DEFAULT_ACCOUNT_ID.to_string()))
89 }
90
91 fn default_api_key(&self) -> Option<String> {
92 self.default_account_id()
93 .and_then(|account_id| self.account_api_key(&account_id))
94 .or_else(|| (!self.api_key.is_empty()).then(|| self.api_key.clone()))
95 }
96
97 fn default_oauth_api_kind(&self) -> Option<String> {
98 self.default_account_id()
99 .and_then(|account_id| self.accounts.get(&account_id))
100 .and_then(|account| account.oauth_api_kind.clone())
101 }
102
103 fn account_api_key(&self, account_id: &str) -> Option<String> {
104 if account_id == DEFAULT_ACCOUNT_ID && !self.api_key.is_empty() {
105 return Some(self.api_key.clone());
106 }
107 self.accounts
108 .get(account_id)
109 .map(|account| account.api_key.clone())
110 }
111
112 fn default_model_api_key(&self) -> Option<String> {
113 self.default_account_id()
114 .and_then(|account_id| self.account_model_api_key(&account_id))
115 .or_else(|| {
116 if !self.api_key.is_empty()
117 && self
118 .default_oauth_api_kind()
119 .as_deref()
120 .is_none_or(|kind| is_model_usable_oauth_kind(kind))
121 {
122 Some(self.api_key.clone())
123 } else {
124 None
125 }
126 })
127 }
128
129 fn account_model_api_key(&self, account_id: &str) -> Option<String> {
130 if account_id == DEFAULT_ACCOUNT_ID
131 && !self.api_key.is_empty()
132 && self
133 .default_oauth_api_kind()
134 .as_deref()
135 .is_none_or(|kind| is_model_usable_oauth_kind(kind))
136 {
137 return Some(self.api_key.clone());
138 }
139 self.accounts
140 .get(account_id)
141 .filter(|account| {
142 account
143 .oauth_api_kind
144 .as_deref()
145 .is_none_or(is_model_usable_oauth_kind)
146 })
147 .map(|account| account.api_key.clone())
148 }
149
150 fn has_oauth_credential(&self) -> bool {
151 self.default_oauth_api_kind().is_some()
152 || self
153 .accounts
154 .values()
155 .any(|account| account.oauth_api_kind.is_some())
156 }
157
158 fn has_non_model_oauth_only(&self) -> bool {
160 self.has_oauth_credential() && self.default_model_api_key().is_none()
161 }
162
163 fn default_oauth_refresh_token(&self) -> Option<String> {
164 self.default_account_id()
165 .and_then(|account_id| self.accounts.get(&account_id))
166 .and_then(|account| account.oauth_refresh_token.clone())
167 }
168
169 fn default_oauth_expires_at(&self) -> Option<i64> {
170 self.default_account_id()
171 .and_then(|account_id| self.accounts.get(&account_id))
172 .and_then(|account| account.oauth_expires_at)
173 }
174
175 fn default_commandcode_metadata(&self) -> Option<CommandCodeCredentialMetadata> {
176 self.default_account_id()
177 .and_then(|account_id| {
178 self.accounts
179 .get(&account_id)
180 .and_then(|account| account.commandcode.clone())
181 })
182 .or_else(|| self.commandcode.clone())
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum CredentialSource {
190 Env,
192 Stored,
194 External,
196 PublicModel,
198}
199
200impl CredentialSource {
201 pub fn as_str(&self) -> &'static str {
203 match self {
204 Self::Env => "env",
205 Self::Stored => "stored",
206 Self::External => "external",
207 Self::PublicModel => "public-model",
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct CredentialStatus {
217 pub configured: bool,
219 pub source: Option<CredentialSource>,
221 pub label: String,
223 pub detail: Option<String>,
225}
226
227#[derive(Debug, Clone)]
229pub struct CredentialStore {
230 path: PathBuf,
231}
232
233impl CredentialStore {
234 pub fn new(data_dir: PathBuf) -> Self {
235 Self {
236 path: data_dir.join("credentials.toml"),
237 }
238 }
239
240 pub fn path(&self) -> &Path {
242 &self.path
243 }
244
245 pub fn get_api_key(&self, provider_id: &str) -> Option<String> {
247 let provider_id = credential_provider_key(provider_id);
248 let content = fs::read_to_string(&self.path).ok()?;
249 let file: CredentialsFile = toml::from_str(&content).ok()?;
250 file.providers
251 .get(&provider_id)
252 .and_then(ProviderCredentials::default_api_key)
253 }
254
255 pub fn get_model_api_key(&self, provider_id: &str) -> Option<String> {
260 let provider_id = credential_provider_key(provider_id);
261 let content = fs::read_to_string(&self.path).ok()?;
262 let file: CredentialsFile = toml::from_str(&content).ok()?;
263 file.providers
264 .get(&provider_id)
265 .and_then(ProviderCredentials::default_model_api_key)
266 }
267
268 pub fn get_oauth_api_kind(&self, provider_id: &str) -> Option<String> {
270 let provider_id = credential_provider_key(provider_id);
271 let content = fs::read_to_string(&self.path).ok()?;
272 let file: CredentialsFile = toml::from_str(&content).ok()?;
273 file.providers
274 .get(&provider_id)
275 .and_then(ProviderCredentials::default_oauth_api_kind)
276 }
277
278 pub fn get_api_key_for_account(&self, provider_id: &str, account_id: &str) -> Option<String> {
279 let provider_id = credential_provider_key(provider_id);
280 let content = fs::read_to_string(&self.path).ok()?;
281 let file: CredentialsFile = toml::from_str(&content).ok()?;
282 let credentials = file.providers.get(&provider_id)?;
283 credentials.account_api_key(account_id)
284 }
285
286 pub fn get_model_api_key_for_account(
287 &self,
288 provider_id: &str,
289 account_id: &str,
290 ) -> Option<String> {
291 let provider_id = credential_provider_key(provider_id);
292 let content = fs::read_to_string(&self.path).ok()?;
293 let file: CredentialsFile = toml::from_str(&content).ok()?;
294 let credentials = file.providers.get(&provider_id)?;
295 credentials.account_model_api_key(account_id)
296 }
297
298 pub fn has_oauth_credential(&self, provider_id: &str) -> bool {
299 let provider_id = credential_provider_key(provider_id);
300 let content = match fs::read_to_string(&self.path) {
301 Ok(content) => content,
302 Err(_) => return false,
303 };
304 let file: CredentialsFile = match toml::from_str(&content) {
305 Ok(file) => file,
306 Err(_) => return false,
307 };
308 file.providers
309 .get(&provider_id)
310 .is_some_and(ProviderCredentials::has_oauth_credential)
311 }
312
313 pub fn get_project_account(&self, project_dir: &Path, provider_id: &str) -> Option<String> {
314 let provider_id = credential_provider_key(provider_id);
315 let content = fs::read_to_string(&self.path).ok()?;
316 let file: CredentialsFile = toml::from_str(&content).ok()?;
317 file.project_accounts
318 .get(&project_account_key(project_dir))
319 .and_then(|providers| providers.get(&provider_id))
320 .cloned()
321 }
322
323 pub fn set_project_account(
324 &self,
325 project_dir: &Path,
326 provider_id: &str,
327 account_id: &str,
328 ) -> Result<()> {
329 let provider_id = credential_provider_key(provider_id);
330 ensure_private_parent_dir(&self.path)?;
331 let mut file = self.load_file()?;
332 let has_account = file
333 .providers
334 .get(&provider_id)
335 .and_then(|credentials| credentials.account_api_key(account_id))
336 .is_some();
337 if !has_account {
338 anyhow::bail!("unknown account '{account_id}' for provider '{provider_id}'");
339 }
340 if let Some(credentials) = file.providers.get_mut(&provider_id) {
341 credentials.default_account = Some(account_id.to_string());
342 }
343 file.project_accounts
344 .entry(project_account_key(project_dir))
345 .or_default()
346 .insert(provider_id, account_id.to_string());
347 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
348 self.write_content(&content)
349 }
350
351 pub fn list_credential_accounts(
352 &self,
353 provider_id: &str,
354 project_dir: Option<&Path>,
355 ) -> Result<Vec<CredentialAccountInfo>> {
356 let provider_id = credential_provider_key(provider_id);
357 let file = self.load_file()?;
358 let Some(credentials) = file.providers.get(&provider_id) else {
359 return Ok(Vec::new());
360 };
361 let default_account = credentials.default_account_id();
362 let project_account = project_dir.and_then(|path| {
363 file.project_accounts
364 .get(&project_account_key(path))
365 .and_then(|providers| providers.get(&provider_id))
366 .cloned()
367 });
368 let selected_account = project_account.as_deref().or(default_account.as_deref());
369 let mut accounts = credentials.accounts.clone();
370 if accounts.is_empty() && !credentials.api_key.is_empty() {
371 accounts.insert(
372 DEFAULT_ACCOUNT_ID.to_string(),
373 CredentialAccount {
374 api_key: credentials.api_key.clone(),
375 label: Some("Default".to_string()),
376 commandcode: credentials.commandcode.clone(),
377 authenticated_at: credentials
378 .commandcode
379 .as_ref()
380 .map(|metadata| metadata.authenticated_at.clone()),
381 oauth_api_kind: None,
382 oauth_refresh_token: None,
383 oauth_expires_at: None,
384 },
385 );
386 }
387 Ok(accounts
388 .into_iter()
389 .map(|(account_id, account)| CredentialAccountInfo {
390 label: account.label.unwrap_or_else(|| account_id.clone()),
391 is_default: default_account.as_deref() == Some(account_id.as_str()),
392 is_project_selected: selected_account == Some(account_id.as_str()),
393 commandcode: account.commandcode,
394 account_id,
395 })
396 .collect())
397 }
398
399 pub fn is_ignored(&self, provider_id: &str) -> bool {
401 let provider_id = credential_provider_key(provider_id);
402 let content = match fs::read_to_string(&self.path) {
403 Ok(c) => c,
404 Err(_) => return false,
405 };
406 let file: CredentialsFile = match toml::from_str(&content) {
407 Ok(f) => f,
408 Err(_) => return false,
409 };
410 file.ignored_providers.contains(&provider_id)
411 }
412
413 pub fn list_api_key_providers(&self) -> Result<Vec<String>> {
415 let mut providers = self.load_file()?.providers.into_keys().collect::<Vec<_>>();
416 providers.sort();
417 Ok(providers)
418 }
419
420 pub fn get_opencode_api_key(&self) -> Option<String> {
422 if let Ok(content) = std::env::var("OPENCODE_AUTH_CONTENT")
423 && let Some(key) = opencode_key_from_auth_content(&content)
424 {
425 return Some(key);
426 }
427
428 opencode_auth_paths()
429 .into_iter()
430 .find_map(|path| opencode_key_from_auth_file(&path))
431 }
432
433 pub fn set_api_key(&self, provider_id: &str, api_key: &str) -> Result<()> {
439 let provider_id = credential_provider_key(provider_id);
440 ensure_private_parent_dir(&self.path)?;
441
442 let mut file = self.load_file()?;
443 file.ignored_providers.retain(|p| p != &provider_id);
444
445 file.providers.insert(
446 provider_id,
447 ProviderCredentials {
448 api_key: api_key.to_string(),
449 commandcode: None,
450 accounts: BTreeMap::from([(
451 DEFAULT_ACCOUNT_ID.to_string(),
452 CredentialAccount {
453 api_key: api_key.to_string(),
454 label: Some("Default".to_string()),
455 commandcode: None,
456 authenticated_at: None,
457 oauth_api_kind: None,
458 oauth_refresh_token: None,
459 oauth_expires_at: None,
460 },
461 )]),
462 default_account: Some(DEFAULT_ACCOUNT_ID.to_string()),
463 },
464 );
465
466 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
467 self.write_content(&content)?;
468
469 Ok(())
470 }
471
472 pub fn add_api_key_account(
476 &self,
477 provider_id: &str,
478 api_key: &str,
479 label: Option<&str>,
480 account_id: Option<&str>,
481 ) -> Result<String> {
482 let provider_id = credential_provider_key(provider_id);
483 let api_key = api_key.trim();
484 if api_key.is_empty() {
485 anyhow::bail!("api key cannot be empty");
486 }
487 ensure_private_parent_dir(&self.path)?;
488
489 let mut file = self.load_file()?;
490 file.ignored_providers.retain(|p| p != &provider_id);
491
492 let credentials = file.providers.entry(provider_id.clone()).or_default();
493 let id = account_id
494 .map(str::trim)
495 .filter(|s| !s.is_empty())
496 .map(str::to_string)
497 .unwrap_or_else(|| {
498 let suffix: String = api_key
500 .chars()
501 .rev()
502 .take(6)
503 .collect::<String>()
504 .chars()
505 .rev()
506 .collect();
507 format!("acct-{}", suffix)
508 });
509 let display = label
510 .map(str::trim)
511 .filter(|s| !s.is_empty())
512 .map(str::to_string)
513 .unwrap_or_else(|| {
514 if id == DEFAULT_ACCOUNT_ID {
515 "Default".to_string()
516 } else {
517 format!("Account {id}")
518 }
519 });
520
521 credentials.accounts.insert(
522 id.clone(),
523 CredentialAccount {
524 api_key: api_key.to_string(),
525 label: Some(display),
526 commandcode: None,
527 authenticated_at: Some(current_unix_timestamp_string()),
528 oauth_api_kind: None,
529 oauth_refresh_token: None,
530 oauth_expires_at: None,
531 },
532 );
533 if credentials.default_account.is_none() {
534 credentials.default_account = Some(id.clone());
535 }
536 if credentials.default_account.as_deref() == Some(id.as_str())
538 || credentials.api_key.is_empty()
539 {
540 credentials.api_key = api_key.to_string();
541 if credentials.default_account.is_none() {
542 credentials.default_account = Some(id.clone());
543 }
544 }
545
546 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
547 self.write_content(&content)?;
548 Ok(id)
549 }
550
551 pub fn set_default_account(&self, provider_id: &str, account_id: &str) -> Result<()> {
553 let provider_id = credential_provider_key(provider_id);
554 ensure_private_parent_dir(&self.path)?;
555 let mut file = self.load_file()?;
556 let Some(credentials) = file.providers.get_mut(&provider_id) else {
557 anyhow::bail!("unknown provider '{provider_id}'");
558 };
559 if credentials.account_api_key(account_id).is_none() {
560 anyhow::bail!("unknown account '{account_id}' for provider '{provider_id}'");
561 }
562 credentials.default_account = Some(account_id.to_string());
563 if let Some(key) = credentials.account_api_key(account_id) {
564 credentials.api_key = key;
565 }
566 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
567 self.write_content(&content)
568 }
569 pub fn set_oauth_credential(
572 &self,
573 provider_id: &str,
574 api_key: &str,
575 oauth_api_kind: &str,
576 ) -> Result<()> {
577 self.set_oauth_credential_full(provider_id, api_key, oauth_api_kind, None, None)
578 }
579
580 pub fn set_oauth_credential_full(
582 &self,
583 provider_id: &str,
584 api_key: &str,
585 oauth_api_kind: &str,
586 refresh_token: Option<&str>,
587 expires_at: Option<i64>,
588 ) -> Result<()> {
589 let provider_id = credential_provider_key(provider_id);
590 ensure_private_parent_dir(&self.path)?;
591
592 let mut file = self.load_file()?;
593 file.ignored_providers.retain(|p| p != &provider_id);
594
595 let label = if is_model_usable_oauth_kind(oauth_api_kind) {
596 "Grok OAuth".to_string()
597 } else {
598 "Default".to_string()
599 };
600
601 file.providers.insert(
602 provider_id,
603 ProviderCredentials {
604 api_key: api_key.to_string(),
605 commandcode: None,
606 accounts: BTreeMap::from([(
607 DEFAULT_ACCOUNT_ID.to_string(),
608 CredentialAccount {
609 api_key: api_key.to_string(),
610 label: Some(label),
611 commandcode: None,
612 authenticated_at: Some(current_unix_timestamp_string()),
613 oauth_api_kind: Some(oauth_api_kind.to_string()),
614 oauth_refresh_token: refresh_token
615 .filter(|value| !value.is_empty())
616 .map(str::to_string),
617 oauth_expires_at: expires_at,
618 },
619 )]),
620 default_account: Some(DEFAULT_ACCOUNT_ID.to_string()),
621 },
622 );
623
624 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
625 self.write_content(&content)?;
626
627 Ok(())
628 }
629
630 pub fn get_oauth_refresh_token(&self, provider_id: &str) -> Option<String> {
632 let provider_id = credential_provider_key(provider_id);
633 let content = fs::read_to_string(&self.path).ok()?;
634 let file: CredentialsFile = toml::from_str(&content).ok()?;
635 file.providers
636 .get(&provider_id)
637 .and_then(ProviderCredentials::default_oauth_refresh_token)
638 }
639
640 pub fn get_oauth_expires_at(&self, provider_id: &str) -> Option<i64> {
642 let provider_id = credential_provider_key(provider_id);
643 let content = fs::read_to_string(&self.path).ok()?;
644 let file: CredentialsFile = toml::from_str(&content).ok()?;
645 file.providers
646 .get(&provider_id)
647 .and_then(ProviderCredentials::default_oauth_expires_at)
648 }
649
650 pub fn set_commandcode_credential(
652 &self,
653 provider_id: &str,
654 api_key: &str,
655 metadata: CommandCodeCredentialMetadata,
656 ) -> Result<String> {
657 let provider_id = credential_provider_key(provider_id);
658 ensure_private_parent_dir(&self.path)?;
659
660 let mut file = self.load_file()?;
661 file.ignored_providers.retain(|p| p != &provider_id);
662 let account_id = commandcode_account_id(&metadata);
663 let credentials = file.providers.entry(provider_id).or_default();
664 credentials.accounts.insert(
665 account_id.clone(),
666 CredentialAccount {
667 api_key: api_key.to_string(),
668 label: Some(commandcode_account_label(&metadata)),
669 authenticated_at: Some(metadata.authenticated_at.clone()),
670 commandcode: Some(metadata),
671 oauth_api_kind: None,
672 oauth_refresh_token: None,
673 oauth_expires_at: None,
674 },
675 );
676 if credentials.default_account.is_none() && credentials.api_key.is_empty() {
677 credentials.default_account = Some(account_id.clone());
678 }
679
680 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
681 self.write_content(&content)?;
682 Ok(account_id)
683 }
684
685 pub fn get_commandcode_metadata(
686 &self,
687 provider_id: &str,
688 ) -> Option<CommandCodeCredentialMetadata> {
689 let provider_id = credential_provider_key(provider_id);
690 let content = fs::read_to_string(&self.path).ok()?;
691 let file: CredentialsFile = toml::from_str(&content).ok()?;
692 file.providers
693 .get(&provider_id)
694 .and_then(ProviderCredentials::default_commandcode_metadata)
695 }
696
697 pub fn delete_credential_account(&self, provider_id: &str, account_id: &str) -> Result<bool> {
698 let provider_id = credential_provider_key(provider_id);
699 let mut file = self.load_file().unwrap_or_default();
700 let Some(credentials) = file.providers.get_mut(&provider_id) else {
701 return Ok(false);
702 };
703 let removed = credentials.accounts.remove(account_id).is_some();
704 if !removed {
705 return Ok(false);
706 }
707 if credentials.default_account.as_deref() == Some(account_id) {
708 credentials.default_account = credentials.accounts.keys().next().cloned();
709 }
710 for providers in file.project_accounts.values_mut() {
711 if providers
712 .get(&provider_id)
713 .is_some_and(|selected| selected == account_id)
714 {
715 providers.remove(&provider_id);
716 }
717 }
718 ensure_private_parent_dir(&self.path)?;
719 let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
720 self.write_content(&content)?;
721 Ok(true)
722 }
723
724 pub fn delete_api_key(&self, provider_id: &str) -> Result<bool> {
727 let provider_id = credential_provider_key(provider_id);
728 let mut file = self.load_file().unwrap_or_default();
729 let removed = file.providers.remove(&provider_id).is_some();
730 for providers in file.project_accounts.values_mut() {
731 providers.remove(&provider_id);
732 }
733 let mut ignored = false;
734 if !file.ignored_providers.contains(&provider_id) {
735 file.ignored_providers.push(provider_id);
736 ignored = true;
737 }
738
739 if removed || ignored {
740 ensure_private_parent_dir(&self.path)?;
741 let content =
742 toml::to_string_pretty(&file).context("failed to serialize credentials")?;
743 self.write_content(&content)?;
744 }
745 Ok(removed)
746 }
747
748 pub fn resolve_api_key(&self, provider_id: &str, env_var: &str) -> Option<String> {
752 if let Ok(key) = std::env::var(env_var)
753 && !key.is_empty()
754 {
755 return Some(key);
756 }
757 self.get_api_key(provider_id)
758 }
759
760 fn load_file(&self) -> Result<CredentialsFile> {
761 if !self.path.exists() {
762 return Ok(CredentialsFile::default());
763 }
764
765 let content = fs::read_to_string(&self.path).context("failed to read credentials file")?;
766 Ok(toml::from_str(&content).unwrap_or_default())
767 }
768
769 fn write_content(&self, content: &str) -> Result<()> {
770 fs::write(&self.path, content)
771 .with_context(|| format!("failed to write {}", self.path.display()))?;
772
773 #[cfg(unix)]
775 {
776 use std::os::unix::fs::PermissionsExt;
777 fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
778 }
779
780 Ok(())
781 }
782}
783
784pub fn resolve_provider_api_key(
787 credential_store: &CredentialStore,
788 provider_config: &ProviderConfig,
789 requested_provider_id: &str,
790) -> Option<String> {
791 if credential_store.is_ignored(&provider_config.id) {
792 return None;
793 }
794
795 provider_env_api_key_for_config(provider_config)
796 .or_else(|| opencode_auth_json_api_key(credential_store, &provider_config.id))
797 .or_else(|| credential_store.get_model_api_key(&provider_config.id))
798 .or_else(|| {
799 if requested_provider_id != provider_config.id {
800 credential_store.get_model_api_key(requested_provider_id)
801 } else {
802 None
803 }
804 })
805 .or_else(|| grok_auth_json_access_token(credential_store, &provider_config.id))
808 .or_else(|| {
809 if requested_provider_id != provider_config.id {
810 grok_auth_json_access_token(credential_store, requested_provider_id)
811 } else {
812 None
813 }
814 })
815}
816
817pub fn resolve_provider_api_key_for_project(
818 credential_store: &CredentialStore,
819 provider_config: &ProviderConfig,
820 requested_provider_id: &str,
821 project_dir: &Path,
822) -> Option<String> {
823 if credential_store.is_ignored(&provider_config.id) {
824 return None;
825 }
826
827 credential_store
828 .get_project_account(project_dir, &provider_config.id)
829 .and_then(|account_id| {
830 credential_store.get_model_api_key_for_account(&provider_config.id, &account_id)
831 })
832 .or_else(|| {
833 if requested_provider_id != provider_config.id {
834 credential_store
835 .get_project_account(project_dir, requested_provider_id)
836 .and_then(|account_id| {
837 credential_store
838 .get_model_api_key_for_account(requested_provider_id, &account_id)
839 })
840 } else {
841 None
842 }
843 })
844 .or_else(|| {
845 resolve_provider_api_key(credential_store, provider_config, requested_provider_id)
846 })
847}
848
849pub fn resolve_provider_credential_status(
852 credential_store: &CredentialStore,
853 provider_config: &ProviderConfig,
854 requested_provider_id: &str,
855 model: Option<&str>,
856) -> CredentialStatus {
857 if credential_store.is_ignored(&provider_config.id) {
858 return CredentialStatus {
859 configured: false,
860 source: None,
861 label: "ignored".to_string(),
862 detail: Some("disabled by user".to_string()),
863 };
864 }
865
866 if let Some(env_var) = provider_env_var_for_config(provider_config) {
867 return CredentialStatus {
868 configured: true,
869 source: Some(CredentialSource::Env),
870 label: "env".to_string(),
871 detail: Some(env_var),
872 };
873 }
874
875 if ProviderId::from_config_id(&provider_config.id).is_opencode_family()
876 && credential_store.get_opencode_api_key().is_some()
877 {
878 return CredentialStatus {
879 configured: true,
880 source: Some(CredentialSource::External),
881 label: "opencode".to_string(),
882 detail: Some("OpenCode auth.json".to_string()),
883 };
884 }
885
886 if credential_store
887 .get_model_api_key(&provider_config.id)
888 .is_some()
889 || (requested_provider_id != provider_config.id
890 && credential_store
891 .get_model_api_key(requested_provider_id)
892 .is_some())
893 {
894 let oauth_kind = credential_store
895 .get_oauth_api_kind(&provider_config.id)
896 .or_else(|| {
897 (requested_provider_id != provider_config.id)
898 .then(|| credential_store.get_oauth_api_kind(requested_provider_id))
899 .flatten()
900 });
901 let (label, detail) = if oauth_kind.as_deref() == Some(XAI_GROK_CLI_OAUTH_KIND) {
902 ("oauth".to_string(), Some("xAI Grok OAuth".to_string()))
903 } else {
904 ("stored".to_string(), Some("stored credential".to_string()))
905 };
906 return CredentialStatus {
907 configured: true,
908 source: Some(CredentialSource::Stored),
909 label,
910 detail,
911 };
912 }
913
914 if is_xai_provider_id(&provider_config.id)
915 && grok_auth_json_access_token(credential_store, &provider_config.id).is_some()
916 {
917 return CredentialStatus {
918 configured: true,
919 source: Some(CredentialSource::External),
920 label: "grok".to_string(),
921 detail: Some("Grok CLI auth.json".to_string()),
922 };
923 }
924
925 if is_non_model_oauth_only(credential_store, &provider_config.id)
926 || (requested_provider_id != provider_config.id
927 && is_non_model_oauth_only(credential_store, requested_provider_id))
928 {
929 return CredentialStatus {
930 configured: false,
931 source: None,
932 label: "oauth-only".to_string(),
933 detail: Some(
934 "stored OAuth credential is not usable for model API calls; configure an API key"
935 .to_string(),
936 ),
937 };
938 }
939
940 if let Some(model) = model
941 && (model_can_run_publicly(requested_provider_id, model)
942 || model_can_run_publicly(&provider_config.id, model))
943 {
944 return CredentialStatus {
945 configured: true,
946 source: Some(CredentialSource::PublicModel),
947 label: "public".to_string(),
948 detail: Some("free model access without key".to_string()),
949 };
950 }
951
952 CredentialStatus {
953 configured: false,
954 source: None,
955 label: "missing".to_string(),
956 detail: None,
957 }
958}
959
960fn provider_env_api_key_for_config(provider_config: &ProviderConfig) -> Option<String> {
961 provider_env_var_for_config(provider_config).and_then(|env_var| provider_env_api_key(&env_var))
962}
963
964fn provider_env_var_for_config(provider_config: &ProviderConfig) -> Option<String> {
965 provider_env_vars_for_config(provider_config)
966 .into_iter()
967 .find(|env_var| provider_env_api_key(env_var).is_some())
968}
969
970fn provider_env_vars_for_config(provider_config: &ProviderConfig) -> Vec<String> {
971 if provider_config.id == ProviderId::COMMANDCODE {
972 let mut env_vars = vec![
973 "COMMAND_CODE_API_KEY".to_string(),
974 "CMD_API_KEY".to_string(),
975 ];
976 if !env_vars
977 .iter()
978 .any(|env_var| env_var == &provider_config.api_key_env)
979 {
980 env_vars.push(provider_config.api_key_env.clone());
981 }
982 return env_vars;
983 }
984
985 if !ProviderId::from_config_id(&provider_config.id).is_opencode_family() {
986 return vec![provider_config.api_key_env.clone()];
987 }
988
989 let mut env_vars = vec![
990 "OPENCODE_API_KEY".to_string(),
991 "OPENCODE_ZEN_API_KEY".to_string(),
992 ];
993 if !env_vars
994 .iter()
995 .any(|env_var| env_var == &provider_config.api_key_env)
996 {
997 env_vars.push(provider_config.api_key_env.clone());
998 }
999 env_vars
1000}
1001
1002fn opencode_auth_json_api_key(
1003 credential_store: &CredentialStore,
1004 provider_id: &str,
1005) -> Option<String> {
1006 if ProviderId::from_config_id(provider_id).is_opencode_family() {
1007 credential_store.get_opencode_api_key()
1008 } else {
1009 None
1010 }
1011}
1012
1013fn is_non_model_oauth_only(credential_store: &CredentialStore, provider_id: &str) -> bool {
1014 let provider_id = credential_provider_key(provider_id);
1015 let content = match fs::read_to_string(credential_store.path()) {
1016 Ok(content) => content,
1017 Err(_) => return false,
1018 };
1019 let file: CredentialsFile = match toml::from_str(&content) {
1020 Ok(file) => file,
1021 Err(_) => return false,
1022 };
1023 file.providers
1024 .get(&provider_id)
1025 .is_some_and(ProviderCredentials::has_non_model_oauth_only)
1026}
1027
1028fn is_xai_provider_id(provider_id: &str) -> bool {
1029 credential_provider_key(provider_id) == ProviderId::XAI
1030}
1031
1032fn grok_auth_json_access_token(
1034 credential_store: &CredentialStore,
1035 provider_id: &str,
1036) -> Option<String> {
1037 if !is_xai_provider_id(provider_id) || credential_store.is_ignored(provider_id) {
1038 return None;
1039 }
1040
1041 if let Ok(content) = std::env::var("GROK_AUTH_CONTENT")
1042 && let Some(key) = grok_key_from_auth_content(&content)
1043 {
1044 return Some(key);
1045 }
1046
1047 grok_auth_paths()
1048 .into_iter()
1049 .find_map(|path| grok_key_from_auth_file(&path))
1050}
1051
1052fn grok_auth_paths() -> Vec<PathBuf> {
1053 let mut paths = Vec::new();
1054 if let Ok(home) = std::env::var("HOME")
1055 && !home.is_empty()
1056 {
1057 paths.push(PathBuf::from(home).join(".grok").join("auth.json"));
1058 }
1059 if let Some(base_dirs) = BaseDirs::new() {
1060 paths.push(base_dirs.home_dir().join(".grok").join("auth.json"));
1061 }
1062 paths.sort();
1063 paths.dedup();
1064 paths
1065}
1066
1067fn grok_key_from_auth_file(path: &Path) -> Option<String> {
1068 let content = fs::read_to_string(path).ok()?;
1069 grok_key_from_auth_content(&content)
1070}
1071
1072fn grok_key_from_auth_content(content: &str) -> Option<String> {
1073 let data: Value = serde_json::from_str(content).ok()?;
1074 let now = current_unix_timestamp_secs();
1075 let mut best: Option<(i64, String)> = None;
1076
1077 for (key, entry) in data.as_object()? {
1078 if !key.contains("auth.x.ai") {
1079 continue;
1080 }
1081 let Some(access) = entry
1082 .get("key")
1083 .or_else(|| entry.get("access_token"))
1084 .and_then(Value::as_str)
1085 .map(str::trim)
1086 .filter(|value| !value.is_empty())
1087 else {
1088 continue;
1089 };
1090
1091 let expires_at = entry
1092 .get("expires_at")
1093 .and_then(Value::as_str)
1094 .and_then(parse_rfc3339_to_unix)
1095 .unwrap_or(i64::MAX);
1096
1097 if expires_at + 300 < now {
1098 continue;
1099 }
1100
1101 match &best {
1102 Some((best_exp, _)) if *best_exp >= expires_at => {}
1103 _ => best = Some((expires_at, access.to_string())),
1104 }
1105 }
1106
1107 best.map(|(_, token)| token)
1108}
1109
1110fn parse_rfc3339_to_unix(value: &str) -> Option<i64> {
1111 let trimmed = value.trim().trim_end_matches('Z');
1112 let (date, time) = trimmed.split_once('T')?;
1113 let mut date_parts = date.split('-');
1114 let year: i64 = date_parts.next()?.parse().ok()?;
1115 let month: i64 = date_parts.next()?.parse().ok()?;
1116 let day: i64 = date_parts.next()?.parse().ok()?;
1117 let time = time.split(['.', '+']).next()?;
1118 let mut time_parts = time.split(':');
1119 let hour: i64 = time_parts.next()?.parse().ok()?;
1120 let minute: i64 = time_parts.next()?.parse().ok()?;
1121 let second: i64 = time_parts.next()?.parse().ok()?;
1122
1123 let y = if month <= 2 { year - 1 } else { year };
1124 let era = y.div_euclid(400);
1125 let yoe = y.rem_euclid(400);
1126 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
1127 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1128 let days = era * 146097 + doe - 719468;
1129 Some(days * 86_400 + hour * 3600 + minute * 60 + second)
1130}
1131
1132fn current_unix_timestamp_secs() -> i64 {
1133 std::time::SystemTime::now()
1134 .duration_since(std::time::UNIX_EPOCH)
1135 .unwrap_or_default()
1136 .as_secs() as i64
1137}
1138
1139fn current_unix_timestamp_string() -> String {
1140 current_unix_timestamp_secs().to_string()
1141}
1142
1143fn project_account_key(project_dir: &Path) -> String {
1144 project_dir
1145 .canonicalize()
1146 .unwrap_or_else(|_| project_dir.to_path_buf())
1147 .to_string_lossy()
1148 .to_string()
1149}
1150
1151fn credential_provider_key(provider_id: &str) -> String {
1152 canonical_provider_id(provider_id).to_string()
1153}
1154
1155fn commandcode_account_id(metadata: &CommandCodeCredentialMetadata) -> String {
1156 let base = if metadata.user_name.trim().is_empty() {
1157 format!("{}-{}", metadata.user_id, metadata.key_name)
1158 } else {
1159 format!("{}-{}", metadata.user_name, metadata.key_name)
1160 };
1161 slugify_account_id(&base)
1162}
1163
1164fn commandcode_account_label(metadata: &CommandCodeCredentialMetadata) -> String {
1165 if metadata.key_name.trim().is_empty() {
1166 metadata.user_name.clone()
1167 } else if metadata.user_name.trim().is_empty() {
1168 metadata.key_name.clone()
1169 } else {
1170 format!("{} ({})", metadata.user_name, metadata.key_name)
1171 }
1172}
1173
1174fn slugify_account_id(value: &str) -> String {
1175 let mut slug = String::new();
1176 let mut last_dash = false;
1177 for ch in value.chars().flat_map(char::to_lowercase) {
1178 if ch.is_ascii_alphanumeric() {
1179 slug.push(ch);
1180 last_dash = false;
1181 } else if !last_dash {
1182 slug.push('-');
1183 last_dash = true;
1184 }
1185 }
1186 let slug = slug.trim_matches('-').to_string();
1187 if slug.is_empty() {
1188 DEFAULT_ACCOUNT_ID.to_string()
1189 } else {
1190 slug
1191 }
1192}
1193
1194fn provider_env_api_key(env_var: &str) -> Option<String> {
1195 let key = std::env::var(env_var).ok()?;
1196 if key.is_empty() { None } else { Some(key) }
1197}
1198
1199fn opencode_auth_paths() -> Vec<PathBuf> {
1200 let mut paths = Vec::new();
1201
1202 if let Ok(data_home) = std::env::var("XDG_DATA_HOME")
1203 && !data_home.is_empty()
1204 {
1205 paths.push(PathBuf::from(data_home).join("opencode").join("auth.json"));
1206 }
1207
1208 if let Some(base_dirs) = BaseDirs::new() {
1209 paths.push(base_dirs.data_dir().join("opencode").join("auth.json"));
1210 }
1211
1212 paths.sort();
1213 paths.dedup();
1214 paths
1215}
1216
1217fn opencode_key_from_auth_file(path: &Path) -> Option<String> {
1218 let content = fs::read_to_string(path).ok()?;
1219 opencode_key_from_auth_content(&content)
1220}
1221
1222fn opencode_key_from_auth_content(content: &str) -> Option<String> {
1223 let data: Value = serde_json::from_str(content).ok()?;
1224 ["opencode", "opencode/"]
1225 .into_iter()
1226 .find_map(|provider_id| api_key_from_opencode_auth_entry(data.get(provider_id)?))
1227}
1228
1229fn api_key_from_opencode_auth_entry(entry: &Value) -> Option<String> {
1230 if entry.get("type")?.as_str()? != "api" {
1231 return None;
1232 }
1233
1234 let key = entry.get("key")?.as_str()?.trim();
1235 if key.is_empty() {
1236 None
1237 } else {
1238 Some(key.to_string())
1239 }
1240}
1241
1242fn ensure_private_parent_dir(path: &Path) -> Result<()> {
1243 if let Some(parent) = path.parent() {
1244 fs::create_dir_all(parent).context("failed to create credentials directory")?;
1245 #[cfg(unix)]
1246 {
1247 use std::os::unix::fs::PermissionsExt;
1248 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
1249 }
1250 }
1251
1252 Ok(())
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use crate::config::{ProviderConfig, ProviderKind};
1259
1260 #[test]
1261 fn roundtrip_store_and_load_api_key() {
1262 let tempdir = tempfile::tempdir().expect("tempdir");
1263 let store = CredentialStore::new(tempdir.path().to_path_buf());
1264
1265 assert!(store.get_api_key("openai").is_none());
1266
1267 store.set_api_key("openai", "sk-test-123").expect("save");
1268 assert_eq!(store.get_api_key("openai").as_deref(), Some("sk-test-123"));
1269 }
1270
1271 #[test]
1272 fn overwrite_existing_key() {
1273 let tempdir = tempfile::tempdir().expect("tempdir");
1274 let store = CredentialStore::new(tempdir.path().to_path_buf());
1275
1276 store.set_api_key("openai", "old-key").expect("save");
1277 store.set_api_key("openai", "new-key").expect("save");
1278 assert_eq!(store.get_api_key("openai").as_deref(), Some("new-key"));
1279 }
1280
1281 #[test]
1282 fn multiple_providers_stored_independently() {
1283 let tempdir = tempfile::tempdir().expect("tempdir");
1284 let store = CredentialStore::new(tempdir.path().to_path_buf());
1285
1286 store.set_api_key("openai", "sk-openai").expect("save");
1287 store.set_api_key("charm-hyper", "sk-charm").expect("save");
1288
1289 assert_eq!(store.get_api_key("openai").as_deref(), Some("sk-openai"));
1290 assert_eq!(
1291 store.get_api_key("charm-hyper").as_deref(),
1292 Some("sk-charm")
1293 );
1294 }
1295
1296 #[test]
1297 fn lists_and_deletes_stored_provider_ids_without_keys() {
1298 let tempdir = tempfile::tempdir().expect("tempdir");
1299 let store = CredentialStore::new(tempdir.path().to_path_buf());
1300
1301 store.set_api_key("z-provider", "sk-z").expect("save");
1302 store.set_api_key("a-provider", "sk-a").expect("save");
1303
1304 assert_eq!(
1305 store.list_api_key_providers().expect("list"),
1306 vec!["a-provider".to_string(), "z-provider".to_string()]
1307 );
1308 assert!(store.delete_api_key("a-provider").expect("delete"));
1309 assert!(!store.delete_api_key("missing-provider").expect("delete"));
1310 assert_eq!(
1311 store.list_api_key_providers().expect("list"),
1312 vec!["z-provider".to_string()]
1313 );
1314 assert_eq!(store.get_api_key("z-provider").as_deref(), Some("sk-z"));
1315 assert!(store.get_api_key("a-provider").is_none());
1316 }
1317
1318 #[test]
1319 fn resolve_prefers_env_var_over_stored() {
1320 let tempdir = tempfile::tempdir().expect("tempdir");
1321 let store = CredentialStore::new(tempdir.path().to_path_buf());
1322
1323 store
1324 .set_api_key("test-provider", "stored-key")
1325 .expect("save");
1326
1327 let key = "NAVI_TEST_RESOLVE_KEY_12345";
1329 unsafe { std::env::set_var(key, "env-key") };
1330 let result = store.resolve_api_key("test-provider", key);
1331 assert_eq!(result.as_deref(), Some("env-key"));
1332 unsafe { std::env::remove_var(key) };
1333 }
1334
1335 #[test]
1336 fn resolve_falls_back_to_stored_key() {
1337 let tempdir = tempfile::tempdir().expect("tempdir");
1338 let store = CredentialStore::new(tempdir.path().to_path_buf());
1339
1340 store
1341 .set_api_key("test-provider", "stored-key")
1342 .expect("save");
1343 let result = store.resolve_api_key("test-provider", "NAVI_NONEXISTENT_ENV_VAR_98765");
1344 assert_eq!(result.as_deref(), Some("stored-key"));
1345 }
1346
1347 #[test]
1348 fn provider_resolver_falls_back_to_store_key() {
1349 let tempdir = tempfile::tempdir().expect("tempdir");
1350 let store = CredentialStore::new(tempdir.path().to_path_buf());
1351 let provider = ProviderConfig {
1352 id: "openai".to_string(),
1353 label: "OpenAI".to_string(),
1354 description: String::new(),
1355 kind: ProviderKind::OpenAiResponses,
1356 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98766".to_string(),
1357 base_url: Some("https://api.openai.com/v1".to_string()),
1358 ..Default::default()
1359 };
1360
1361 store.set_api_key("openai", "stored-openai").expect("save");
1362
1363 let result = resolve_provider_api_key(&store, &provider, "openai");
1364 assert_eq!(result.as_deref(), Some("stored-openai"));
1365 }
1366
1367 #[test]
1368 fn provider_resolver_checks_requested_alias_key() {
1369 let tempdir = tempfile::tempdir().expect("tempdir");
1370 let store = CredentialStore::new(tempdir.path().to_path_buf());
1371 let provider = ProviderConfig {
1372 id: "custom-provider".to_string(),
1373 label: "Custom Provider".to_string(),
1374 description: String::new(),
1375 kind: ProviderKind::OpenAiResponses,
1376 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98767".to_string(),
1377 base_url: Some("https://example.test/v1".to_string()),
1378 ..Default::default()
1379 };
1380
1381 store
1382 .set_api_key("custom-provider-alias", "stored-alias")
1383 .expect("save");
1384
1385 let result = resolve_provider_api_key(&store, &provider, "custom-provider-alias");
1386 assert_eq!(result.as_deref(), Some("stored-alias"));
1387 }
1388
1389 #[test]
1390 fn provider_status_reports_stored_and_missing_credentials() {
1391 let tempdir = tempfile::tempdir().expect("tempdir");
1392 let store = CredentialStore::new(tempdir.path().to_path_buf());
1393 let provider = ProviderConfig {
1394 id: "openai".to_string(),
1395 label: "OpenAI".to_string(),
1396 description: String::new(),
1397 kind: ProviderKind::OpenAiResponses,
1398 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98768".to_string(),
1399 base_url: Some("https://api.openai.com/v1".to_string()),
1400 ..Default::default()
1401 };
1402
1403 let missing = resolve_provider_credential_status(&store, &provider, "openai", None);
1404 assert!(!missing.configured);
1405 assert_eq!(missing.label, "missing");
1406
1407 store.set_api_key("openai", "stored-openai").expect("save");
1408 let stored = resolve_provider_credential_status(&store, &provider, "openai", None);
1409 assert!(stored.configured);
1410 assert_eq!(stored.source, Some(CredentialSource::Stored));
1411 assert_eq!(stored.label, "stored");
1412 }
1413
1414 #[test]
1415 fn openai_oauth_credential_does_not_resolve_as_model_api_key() {
1416 let tempdir = tempfile::tempdir().expect("tempdir");
1417 let store = CredentialStore::new(tempdir.path().to_path_buf());
1418 let provider = ProviderConfig {
1419 id: "openai".to_string(),
1420 label: "OpenAI".to_string(),
1421 description: String::new(),
1422 kind: ProviderKind::OpenAiResponses,
1423 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98771".to_string(),
1424 base_url: Some("https://api.openai.com/v1".to_string()),
1425 ..Default::default()
1426 };
1427
1428 store
1429 .set_oauth_credential("openai", "oauth-access-token", "chat-completions")
1430 .expect("save oauth");
1431
1432 assert_eq!(
1433 store.get_api_key("openai").as_deref(),
1434 Some("oauth-access-token")
1435 );
1436 assert!(store.get_model_api_key("openai").is_none());
1437 assert!(resolve_provider_api_key(&store, &provider, "openai").is_none());
1438
1439 let status = resolve_provider_credential_status(&store, &provider, "openai", None);
1440 assert!(!status.configured);
1441 assert_eq!(status.label, "oauth-only");
1442 }
1443
1444 #[test]
1445 fn xai_grok_oauth_credential_resolves_as_model_api_key() {
1446 let tempdir = tempfile::tempdir().expect("tempdir");
1447 let store = CredentialStore::new(tempdir.path().to_path_buf());
1448 let provider = ProviderConfig {
1449 id: "xai".to_string(),
1450 label: "xAI".to_string(),
1451 description: String::new(),
1452 kind: ProviderKind::OpenAiResponses,
1453 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_XAI_OAUTH".to_string(),
1454 base_url: Some("https://api.x.ai/v1".to_string()),
1455 ..Default::default()
1456 };
1457
1458 store
1459 .set_oauth_credential_full(
1460 "xai",
1461 "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.payload.sig",
1462 XAI_GROK_CLI_OAUTH_KIND,
1463 Some("refresh-token-xyz"),
1464 Some(9_999_999_999),
1465 )
1466 .expect("save oauth");
1467
1468 assert_eq!(
1469 store.get_model_api_key("xai").as_deref(),
1470 Some("eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.payload.sig")
1471 );
1472 assert_eq!(
1473 store.get_oauth_api_kind("xai").as_deref(),
1474 Some(XAI_GROK_CLI_OAUTH_KIND)
1475 );
1476 assert_eq!(
1477 store.get_oauth_refresh_token("xai").as_deref(),
1478 Some("refresh-token-xyz")
1479 );
1480 assert!(resolve_provider_api_key(&store, &provider, "xai").is_some());
1481
1482 let status = resolve_provider_credential_status(&store, &provider, "xai", None);
1483 assert!(status.configured);
1484 assert_eq!(status.label, "oauth");
1485 }
1486
1487 #[test]
1488 fn grok_auth_json_content_is_used_as_external_xai_credential() {
1489 let tempdir = tempfile::tempdir().expect("tempdir");
1490 let store = CredentialStore::new(tempdir.path().to_path_buf());
1491 let provider = ProviderConfig {
1492 id: "xai".to_string(),
1493 label: "xAI".to_string(),
1494 description: String::new(),
1495 kind: ProviderKind::OpenAiResponses,
1496 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_XAI_GROK".to_string(),
1497 base_url: Some("https://api.x.ai/v1".to_string()),
1498 ..Default::default()
1499 };
1500
1501 let far_future = "2099-01-01T00:00:00Z";
1502 let content = format!(
1503 r#"{{
1504 "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
1505 "key": "eyJ.test.token",
1506 "auth_mode": "oidc",
1507 "expires_at": "{far_future}",
1508 "oidc_issuer": "https://auth.x.ai"
1509 }}
1510 }}"#
1511 );
1512 unsafe { std::env::set_var("GROK_AUTH_CONTENT", &content) };
1514 let key = resolve_provider_api_key(&store, &provider, "xai");
1515 let status = resolve_provider_credential_status(&store, &provider, "xai", None);
1516 unsafe { std::env::remove_var("GROK_AUTH_CONTENT") };
1517
1518 assert_eq!(key.as_deref(), Some("eyJ.test.token"));
1519 assert!(status.configured);
1520 assert_eq!(status.label, "grok");
1521 }
1522
1523 #[test]
1524 fn provider_status_reports_public_model_access() {
1525 let tempdir = tempfile::tempdir().expect("tempdir");
1526 let store = CredentialStore::new(tempdir.path().to_path_buf());
1527 let provider = ProviderConfig {
1528 id: "public-test".to_string(),
1529 label: "OpenCode".to_string(),
1530 description: String::new(),
1531 kind: ProviderKind::OpenAiChatCompletions,
1532 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98769".to_string(),
1533 base_url: None,
1534 ..Default::default()
1535 };
1536
1537 let status = resolve_provider_credential_status(
1538 &store,
1539 &provider,
1540 "opencode",
1541 Some("deepseek-v4-flash-free"),
1542 );
1543 assert!(status.configured);
1544 assert!(matches!(
1545 status.source,
1546 Some(CredentialSource::External) | Some(CredentialSource::PublicModel)
1547 ));
1548 }
1549
1550 #[test]
1551 fn reads_opencode_api_key_from_auth_content() {
1552 let content = r#"{
1553 "opencode": {
1554 "type": "api",
1555 "key": "zen-key"
1556 },
1557 "openai": {
1558 "type": "api",
1559 "key": "openai-key"
1560 }
1561 }"#;
1562
1563 assert_eq!(
1564 opencode_key_from_auth_content(content).as_deref(),
1565 Some("zen-key")
1566 );
1567 }
1568
1569 #[test]
1570 fn ignores_non_api_opencode_auth_content() {
1571 let content = r#"{
1572 "opencode": {
1573 "type": "oauth",
1574 "access": "access-token",
1575 "refresh": "refresh-token",
1576 "expires": 999999
1577 }
1578 }"#;
1579
1580 assert!(opencode_key_from_auth_content(content).is_none());
1581 }
1582
1583 #[test]
1584 fn stores_commandcode_oauth_metadata_in_navi_credentials() {
1585 let tempdir = tempfile::tempdir().expect("tempdir");
1586 let store = CredentialStore::new(tempdir.path().to_path_buf());
1587 let metadata = CommandCodeCredentialMetadata {
1588 user_id: "user-1".to_string(),
1589 user_name: "test-user".to_string(),
1590 key_name: "NAVI".to_string(),
1591 authenticated_at: "123".to_string(),
1592 };
1593
1594 store
1595 .set_commandcode_credential(ProviderId::COMMANDCODE, "cmd-key", metadata.clone())
1596 .expect("save commandcode credential");
1597
1598 assert_eq!(
1599 store.get_api_key(ProviderId::COMMANDCODE).as_deref(),
1600 Some("cmd-key")
1601 );
1602 assert_eq!(
1603 store.get_commandcode_metadata(ProviderId::COMMANDCODE),
1604 Some(metadata)
1605 );
1606 }
1607
1608 #[test]
1609 fn commandcode_provider_checks_cli_env_aliases() {
1610 let provider = ProviderConfig {
1611 id: ProviderId::COMMANDCODE.to_string(),
1612 label: "Command Code".to_string(),
1613 description: String::new(),
1614 kind: ProviderKind::OpenAiChatCompletions,
1615 api_key_env: "CMD_API_KEY".to_string(),
1616 base_url: Some("https://api.commandcode.ai".to_string()),
1617 ..Default::default()
1618 };
1619
1620 assert_eq!(
1621 provider_env_vars_for_config(&provider),
1622 vec![
1623 "COMMAND_CODE_API_KEY".to_string(),
1624 "CMD_API_KEY".to_string()
1625 ]
1626 );
1627 }
1628
1629 #[test]
1630 fn provider_resolver_uses_stored_commandcode_oauth_key() {
1631 let tempdir = tempfile::tempdir().expect("tempdir");
1632 let store = CredentialStore::new(tempdir.path().to_path_buf());
1633 let provider = ProviderConfig {
1634 id: ProviderId::COMMANDCODE.to_string(),
1635 label: "Command Code".to_string(),
1636 description: String::new(),
1637 kind: ProviderKind::OpenAiChatCompletions,
1638 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98770".to_string(),
1639 base_url: Some("https://api.commandcode.ai".to_string()),
1640 ..Default::default()
1641 };
1642
1643 store
1644 .set_commandcode_credential(
1645 ProviderId::COMMANDCODE,
1646 "cmd-stored-key",
1647 CommandCodeCredentialMetadata {
1648 user_id: "user-1".to_string(),
1649 user_name: "test-user".to_string(),
1650 key_name: "NAVI".to_string(),
1651 authenticated_at: "123".to_string(),
1652 },
1653 )
1654 .expect("save commandcode credential");
1655 let result = resolve_provider_api_key(&store, &provider, ProviderId::COMMANDCODE);
1656 let expected = std::env::var("COMMAND_CODE_API_KEY")
1657 .ok()
1658 .filter(|key| !key.is_empty())
1659 .or_else(|| {
1660 std::env::var("CMD_API_KEY")
1661 .ok()
1662 .filter(|key| !key.is_empty())
1663 })
1664 .unwrap_or_else(|| "cmd-stored-key".to_string());
1665
1666 assert_eq!(result.as_deref(), Some(expected.as_str()));
1667 }
1668
1669 #[test]
1670 fn selected_provider_account_persists_as_project_and_default_account() {
1671 let tempdir = tempfile::tempdir().expect("tempdir");
1672 let data_dir = tempdir.path().join("data");
1673 let project_dir = tempdir.path().join("project");
1674 fs::create_dir_all(&project_dir).expect("project dir");
1675 let store = CredentialStore::new(data_dir.clone());
1676 let provider = ProviderConfig {
1677 id: ProviderId::COMMANDCODE.to_string(),
1678 label: "Command Code".to_string(),
1679 description: String::new(),
1680 kind: ProviderKind::OpenAiChatCompletions,
1681 api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98772".to_string(),
1682 base_url: Some("https://api.commandcode.ai".to_string()),
1683 ..Default::default()
1684 };
1685
1686 let first = store
1687 .set_commandcode_credential(
1688 ProviderId::COMMANDCODE,
1689 "cmd-first-key",
1690 CommandCodeCredentialMetadata {
1691 user_id: "user-1".to_string(),
1692 user_name: "first-user".to_string(),
1693 key_name: "NAVI".to_string(),
1694 authenticated_at: "123".to_string(),
1695 },
1696 )
1697 .expect("save first commandcode credential");
1698 let second = store
1699 .set_commandcode_credential(
1700 ProviderId::COMMANDCODE,
1701 "cmd-second-key",
1702 CommandCodeCredentialMetadata {
1703 user_id: "user-2".to_string(),
1704 user_name: "second-user".to_string(),
1705 key_name: "NAVI".to_string(),
1706 authenticated_at: "456".to_string(),
1707 },
1708 )
1709 .expect("save second commandcode credential");
1710
1711 assert_ne!(first, second);
1712 store
1713 .set_project_account(&project_dir, ProviderId::COMMANDCODE, &second)
1714 .expect("select project account");
1715
1716 let reopened = CredentialStore::new(data_dir);
1717 assert_eq!(
1718 reopened.get_project_account(&project_dir, ProviderId::COMMANDCODE),
1719 Some(second.clone())
1720 );
1721 assert_eq!(
1722 reopened.get_api_key(ProviderId::COMMANDCODE).as_deref(),
1723 Some("cmd-second-key")
1724 );
1725 assert_eq!(
1726 resolve_provider_api_key_for_project(
1727 &reopened,
1728 &provider,
1729 ProviderId::COMMANDCODE,
1730 &project_dir
1731 )
1732 .as_deref(),
1733 Some("cmd-second-key")
1734 );
1735
1736 let accounts = reopened
1737 .list_credential_accounts(ProviderId::COMMANDCODE, Some(&project_dir))
1738 .expect("list accounts");
1739 assert!(
1740 accounts
1741 .iter()
1742 .any(|account| account.account_id == second && account.is_project_selected)
1743 );
1744 let accounts_without_project = reopened
1745 .list_credential_accounts(ProviderId::COMMANDCODE, None)
1746 .expect("list accounts without project");
1747 assert!(
1748 accounts_without_project
1749 .iter()
1750 .any(|account| account.account_id == second && account.is_project_selected)
1751 );
1752 }
1753
1754 #[cfg(unix)]
1755 #[test]
1756 fn credentials_file_and_directory_are_private() {
1757 use std::os::unix::fs::PermissionsExt;
1758
1759 let tempdir = tempfile::tempdir().expect("tempdir");
1760 let data_dir = tempdir.path().join("navi-data");
1761 let store = CredentialStore::new(data_dir.clone());
1762
1763 store.set_api_key("openai", "sk-test").expect("save");
1764
1765 let dir_mode = fs::metadata(&data_dir)
1766 .expect("dir metadata")
1767 .permissions()
1768 .mode()
1769 & 0o777;
1770 let file_mode = fs::metadata(data_dir.join("credentials.toml"))
1771 .expect("file metadata")
1772 .permissions()
1773 .mode()
1774 & 0o777;
1775
1776 assert_eq!(dir_mode, 0o700);
1777 assert_eq!(file_mode, 0o600);
1778 }
1779
1780 #[test]
1783 fn regression_corrupt_credentials_file_returns_none() {
1784 let tempdir = tempfile::tempdir().expect("tempdir");
1785 let data_dir = tempdir.path().join("navi-data");
1786 let store = CredentialStore::new(data_dir.clone());
1787
1788 fs::create_dir_all(&data_dir).expect("create");
1790 fs::write(data_dir.join("credentials.toml"), "{not valid toml!!!").expect("write");
1791
1792 let key = store.get_api_key("openai");
1793 assert!(key.is_none(), "corrupt credentials must return None");
1794 }
1795
1796 #[test]
1797 fn regression_delete_api_key_missing_file_returns_false() {
1798 let tempdir = tempfile::tempdir().expect("tempdir");
1799 let data_dir = tempdir.path().join("navi-data");
1800 let store = CredentialStore::new(data_dir);
1801
1802 let result = store.delete_api_key("openai").expect("delete");
1803 assert!(!result, "deleting from missing file should return false");
1804 }
1805
1806 #[test]
1807 fn regression_empty_env_var_falls_through() {
1808 let tempdir = tempfile::tempdir().expect("tempdir");
1809 let data_dir = tempdir.path().join("navi-data");
1810 let store = CredentialStore::new(data_dir);
1811
1812 store.set_api_key("openai", "sk-stored").expect("save");
1814
1815 unsafe { std::env::set_var("OPENAI_API_KEY", "") };
1817 let key = store.resolve_api_key("openai", "OPENAI_API_KEY");
1818 unsafe { std::env::remove_var("OPENAI_API_KEY") };
1819
1820 assert_eq!(key.as_deref(), Some("sk-stored"));
1822 }
1823
1824 #[test]
1825 fn regression_set_empty_api_key_stores_empty() {
1826 let tempdir = tempfile::tempdir().expect("tempdir");
1827 let data_dir = tempdir.path().join("navi-data");
1828 let store = CredentialStore::new(data_dir);
1829
1830 store.set_api_key("openai", "").expect("save");
1831 let key = store.get_api_key("openai");
1834 assert_eq!(key.as_deref(), Some(""));
1835 }
1836
1837 #[test]
1838 fn regression_opencode_key_from_invalid_json_returns_none() {
1839 let result = opencode_key_from_auth_content("{not json");
1840 assert!(result.is_none());
1841 }
1842}