1use std::io::{self, Write};
23use std::sync::Arc;
24
25use crate::config::{
26 self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
27};
28use crate::credentials::CredentialSource;
29use rpi_runtime::credentials::{oauth_needs_refresh, OAuthRefresher, RefreshedOAuth};
30const EXIT_NOT_READY: i32 = 1;
33const EXIT_ERROR: i32 = 2;
35
36pub async fn run(args: &[String]) -> i32 {
40 run_with_extensions(args, None).await
41}
42
43pub async fn run_with_extensions(
46 args: &[String],
47 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
48) -> i32 {
49 let sub = args.first().map(|s| s.as_str()).unwrap_or("");
50 match sub {
51 "login" => run_login(&args[1..], snapshot.as_ref()).await,
52 "check" | "status" => run_check(&args[1..], snapshot.as_ref()).await,
53 "refresh" => run_refresh(&args[1..], snapshot.as_ref()).await,
54 "logout" => run_logout(&args[1..], snapshot.as_ref()).await,
55 "--help" | "-h" | "help" | "" => {
56 print_auth_help();
57 0
58 }
59 other => {
60 eprintln!("error: unknown auth subcommand \"{other}\"");
61 eprintln!();
62 print_auth_help();
63 EXIT_ERROR
64 }
65 }
66}
67
68async fn run_login(
70 args: &[String],
71 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
72) -> i32 {
73 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
74 if let Some(snapshot) = snapshot {
75 if snapshot.oauth_provider(provider).is_some() {
76 return run_oauth_login(provider, snapshot).await;
77 }
78 }
79 eprint!("Enter API key for {provider}: ");
82 let key = match rpassword::read_password() {
83 Ok(k) => k,
84 Err(e) => {
85 eprintln!();
86 eprintln!("error: could not read the key from the terminal: {e}");
87 return EXIT_ERROR;
88 }
89 };
90 eprintln!();
91 let key = key.trim();
92 if key.is_empty() {
93 eprintln!("error: an empty key was entered; nothing saved.");
94 return EXIT_ERROR;
95 }
96 let cred = Credential::ApiKey {
97 key: Some(key.to_string()),
98 env: None,
99 };
100 if let Err(e) = upsert_credential(provider, cred) {
101 eprintln!("error: could not save credentials: {e}");
102 return EXIT_ERROR;
103 }
104 let path = match config::auth_path() {
105 Ok(p) => p,
106 Err(e) => {
107 eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
108 return 0;
109 }
110 };
111 println!("Credentials saved to {}", path.display());
112 println!("Run `rpi auth check --provider {provider}` to verify.");
113 0
114}
115
116async fn run_check(
118 args: &[String],
119 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
120) -> i32 {
121 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
122 let want_json = args.iter().any(|a| a == "--json");
123
124 let source = match detect_credential(provider, snapshot) {
125 Ok(source) => source,
126 Err(error) => {
127 eprintln!("error: could not read authentication configuration: {error}");
128 return EXIT_ERROR;
129 }
130 };
131 let ready = source.is_present();
132 if want_json {
133 let json = serde_json::json!({
134 "ready": ready,
135 "provider": provider,
136 "source": source.as_json_str(),
137 });
138 println!("{json}");
139 } else if ready {
140 println!("ready ({})", source.as_json_str());
141 } else {
142 println!("not_ready — no credentials found.");
143 eprintln!();
144 eprintln!("Run `rpi auth login --provider {provider}`, configure this provider's apiKey in models.json, or pass --api-key when starting rpi.");
145 let env = match snapshot.and_then(|snapshot| snapshot.provider_manifest(provider)) {
146 Some(manifest) => manifest.api_key_env.as_deref(),
147 None => crate::credentials::builtin_api_key_env(provider),
148 };
149 if let Some(env) = env {
150 eprintln!("You can also set {env}.");
151 }
152 }
153 if ready {
154 0
155 } else {
156 EXIT_NOT_READY
157 }
158}
159
160async fn run_refresh(
162 args: &[String],
163 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
164) -> i32 {
165 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
166 let Some(snapshot) = snapshot else {
167 eprintln!("error: no OAuth extensions are loaded");
168 return EXIT_ERROR;
169 };
170 match refresh_stored_oauth(snapshot, provider, false) {
171 Ok(_) => {
172 println!("OAuth credentials refreshed for \"{provider}\".");
173 0
174 }
175 Err(RefreshError::NotStored) => {
176 eprintln!("error: no stored OAuth credentials for \"{provider}\"");
177 EXIT_NOT_READY
178 }
179 Err(error) => {
180 eprintln!("error: {error}");
181 EXIT_ERROR
182 }
183 }
184}
185
186enum RefreshError {
188 NotStored,
189 Read,
190 Plugin(rpi_extensions::OAuthExtensionError),
191 Invalid,
192 Save,
193}
194
195impl std::fmt::Display for RefreshError {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 Self::NotStored => f.write_str("no stored OAuth credentials"),
199 Self::Read => f.write_str("could not read stored OAuth credentials"),
200 Self::Plugin(rpi_extensions::OAuthExtensionError::PluginStatus(status, _)) => {
201 write!(f, "the provider package returned status {status}")
202 }
203 Self::Plugin(_) => f.write_str("the provider package could not refresh the login"),
204 Self::Invalid => f.write_str("OAuth refresh returned an invalid or expired credential"),
205 Self::Save => f.write_str("could not save refreshed OAuth credentials"),
206 }
207 }
208}
209
210fn renewal_lock(provider: &str) -> Arc<std::sync::Mutex<()>> {
214 static LOCKS: std::sync::OnceLock<
215 std::sync::Mutex<std::collections::HashMap<String, Arc<std::sync::Mutex<()>>>>,
216 > = std::sync::OnceLock::new();
217 let locks = LOCKS.get_or_init(Default::default);
218 let mut locks = locks
219 .lock()
220 .unwrap_or_else(std::sync::PoisonError::into_inner);
221 Arc::clone(locks.entry(provider.to_owned()).or_default())
222}
223
224fn refresh_stored_oauth(
227 snapshot: &Arc<rpi_extensions::RegistrySnapshot>,
228 provider: &str,
229 only_if_needed: bool,
230) -> Result<RefreshedOAuth, RefreshError> {
231 let renewal = renewal_lock(provider);
237 let _renewing = renewal
238 .lock()
239 .unwrap_or_else(std::sync::PoisonError::into_inner);
240 let Some(Credential::Oauth {
241 access,
242 refresh,
243 expires,
244 }) = config::read_oauth_token(provider).map_err(|_| RefreshError::Read)?
245 else {
246 return Err(RefreshError::NotStored);
247 };
248 let now = || {
249 std::time::SystemTime::now()
250 .duration_since(std::time::UNIX_EPOCH)
251 .map_or(i64::MAX, |duration| duration.as_secs() as i64)
252 };
253 if only_if_needed && !access.trim().is_empty() && !oauth_needs_refresh(expires, now()) {
255 return Ok(RefreshedOAuth { access, expires });
256 }
257 if refresh.trim().is_empty() {
258 return Err(RefreshError::Invalid);
259 }
260 let started_from = (access.clone(), refresh.clone(), expires);
261 let response = rpi_extensions::request_oauth(
262 snapshot,
263 provider,
264 "refresh",
265 serde_json::json!({"credential": {"access": access, "refresh": refresh, "expires_at": expires}}),
266 )
267 .map_err(RefreshError::Plugin)?;
268 let response =
269 rpi_extensions::OAuthActionResponse::parse(response).map_err(|_| RefreshError::Invalid)?;
270 if response
271 .payload
272 .get("expires_at")
273 .is_some_and(|value| value.as_i64().is_none())
274 || response
275 .payload
276 .get("refresh")
277 .is_some_and(|value| !value.is_string())
278 {
279 return Err(RefreshError::Invalid);
280 }
281 let token = response
282 .credential()
283 .map_err(|_| RefreshError::Invalid)?
284 .ok_or(RefreshError::Invalid)?;
285 if token.access.trim().is_empty() || (token.expires_at != 0 && token.expires_at <= now()) {
286 return Err(RefreshError::Invalid);
287 }
288 let refresh = if token.refresh.trim().is_empty() {
290 refresh
291 } else {
292 token.refresh
293 };
294 let committed = config::replace_oauth_token_if_unchanged(
295 provider,
296 (&started_from.0, &started_from.1, started_from.2),
297 token.access.clone(),
298 refresh,
299 token.expires_at,
300 )
301 .map_err(|_| RefreshError::Save)?;
302 if !committed {
303 return match config::read_oauth_token(provider).map_err(|_| RefreshError::Read)? {
306 Some(Credential::Oauth {
307 access, expires, ..
308 }) => Ok(RefreshedOAuth { access, expires }),
309 _ => Err(RefreshError::NotStored),
310 };
311 }
312 Ok(RefreshedOAuth {
313 access: token.access,
314 expires: token.expires_at,
315 })
316}
317
318pub(crate) struct PackageOAuthRefresher {
321 snapshot: Arc<rpi_extensions::RegistrySnapshot>,
322 _keepalive: Arc<rpi_extensions::PluginKeepalive>,
323}
324
325impl PackageOAuthRefresher {
326 pub(crate) fn new(session: &rpi_extensions::ExtensionSession) -> Option<Self> {
327 Some(Self {
328 snapshot: session.snapshot_arc()?,
329 _keepalive: session.keepalive(),
330 })
331 }
332}
333
334impl OAuthRefresher for PackageOAuthRefresher {
335 fn supports(&self, provider: &str) -> bool {
336 self.snapshot.oauth_provider(provider).is_some()
337 }
338
339 fn refresh(&self, provider: &str) -> Result<RefreshedOAuth, String> {
340 refresh_stored_oauth(&self.snapshot, provider, true).map_err(|error| error.to_string())
341 }
342}
343
344async fn run_logout(
345 args: &[String],
346 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
347) -> i32 {
348 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
349 if let Ok(Some(Credential::Oauth {
350 access, refresh, ..
351 })) = config::read_oauth_token(provider)
352 {
353 let Some(snapshot) = snapshot else {
354 eprintln!("error: cannot revoke OAuth credentials: no OAuth extensions are loaded");
355 return EXIT_ERROR;
356 };
357 let response = match rpi_extensions::request_oauth(
358 snapshot,
359 provider,
360 "revoke",
361 serde_json::json!({"credential": {"access": access, "refresh": refresh}}),
362 ) {
363 Ok(response) => response,
364 Err(error) => {
365 eprintln!("error: OAuth revoke failed; local credentials were kept: {error}");
366 return EXIT_ERROR;
367 }
368 };
369 match rpi_extensions::OAuthActionResponse::parse(response) {
370 Ok(response) if response.kind == rpi_extensions::OAuthResponseKind::Success => {}
371 Ok(_) => {
372 eprintln!(
373 "error: OAuth revoke did not return success; local credentials were kept"
374 );
375 return EXIT_ERROR;
376 }
377 Err(error) => {
378 eprintln!(
379 "error: invalid OAuth revoke response; local credentials were kept: {error}"
380 );
381 return EXIT_ERROR;
382 }
383 }
384 }
387 match delete_credential(provider) {
388 Ok(true) => {
389 println!("Removed stored credentials for \"{provider}\".");
390 0
391 }
392 Ok(false) => {
393 println!("No stored credential for \"{provider}\" (nothing to do).");
394 0
395 }
396 Err(e) => {
397 eprintln!("error: could not remove credentials: {e}");
398 EXIT_ERROR
399 }
400 }
401}
402
403async fn run_oauth_login(provider: &str, snapshot: &Arc<rpi_extensions::RegistrySnapshot>) -> i32 {
407 let mut input = serde_json::json!({});
408 let mut action = "begin";
409 loop {
410 let raw = match rpi_extensions::request_oauth(snapshot, provider, action, input) {
411 Ok(value) => value,
412 Err(error) => {
413 eprintln!("error: OAuth {action} failed: {error}");
414 return EXIT_ERROR;
415 }
416 };
417 let response = match rpi_extensions::OAuthActionResponse::parse(raw) {
418 Ok(response) => response,
419 Err(error) => {
420 eprintln!("error: invalid OAuth response: {error}");
421 return EXIT_ERROR;
422 }
423 };
424 match response.kind {
425 rpi_extensions::OAuthResponseKind::Credential => {
426 let Some(token) = response.credential().ok().flatten() else {
427 eprintln!("error: OAuth response contained no valid credential");
428 return EXIT_ERROR;
429 };
430 if let Err(error) = config::upsert_oauth_token(
431 provider,
432 token.access,
433 token.refresh,
434 token.expires_at,
435 ) {
436 eprintln!("error: could not save OAuth credentials: {error}");
437 return EXIT_ERROR;
438 }
439 println!("OAuth credentials saved for \"{provider}\".");
440 return 0;
441 }
442 rpi_extensions::OAuthResponseKind::Error => {
443 eprintln!("error: OAuth provider rejected the request");
444 return EXIT_ERROR;
445 }
446 rpi_extensions::OAuthResponseKind::Success => {
447 eprintln!("error: OAuth login completed without credentials");
448 return EXIT_ERROR;
449 }
450 rpi_extensions::OAuthResponseKind::Interaction => {
451 let display = response.payload.get("display").unwrap_or(&response.payload);
452 if let Some(title) = display.get("title").and_then(serde_json::Value::as_str) {
453 println!("{title}");
454 }
455 if let Some(url) = display.get("url").and_then(serde_json::Value::as_str) {
456 println!("{url}");
457 }
458 if let Some(instructions) = display
459 .get("instructions")
460 .and_then(serde_json::Value::as_array)
461 {
462 for instruction in instructions.iter().filter_map(serde_json::Value::as_str) {
463 println!("{instruction}");
464 }
465 }
466 eprint!("OAuth input: ");
467 let _ = io::stderr().flush();
468 let mut line = String::new();
469 if io::stdin().read_line(&mut line).is_err() || line.trim().is_empty() {
470 eprintln!("error: empty OAuth input; nothing saved.");
471 return EXIT_ERROR;
472 }
473 input = serde_json::json!({"value": line.trim()});
474 action = "exchange";
475 }
476 }
477 }
478}
479
480fn parse_provider(args: &[String]) -> Option<&str> {
481 let mut iter = args.iter();
482 while let Some(a) = iter.next() {
483 if a == "--provider" {
484 if let Some(v) = iter.next() {
485 return Some(v.as_str());
486 }
487 } else if let Some(rest) = a.strip_prefix("--provider=") {
488 return Some(rest);
489 }
490 }
491 None
492}
493
494fn detect_credential(
497 provider: &str,
498 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
499) -> Result<CredentialSource, config::ConfigError> {
500 let store = read_auth()?;
501 let models = config::load_models_config()?;
502 let manifests = snapshot.map_or_else(Vec::new, |snapshot| {
503 snapshot
504 .providers()
505 .iter()
506 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
507 .collect()
508 });
509 Ok(crate::credentials::status(provider, &models, &store, &manifests).source)
510}
511
512fn print_auth_help() {
514 println!(
515 "Usage: {name} auth <subcommand> [options]
516
517Manage persisted credentials and inspect provider package authentication.
518
519Subcommands:
520 login Prompt for an API key, or run an OAuth provider login.
521 check Report whether credentials are available (no network call).
522 status Alias for check; never prints token material.
523 refresh Refresh an OAuth token through its provider extension.
524 logout Revoke OAuth credentials, then remove the stored credential.
525
526Options:
527 --provider <id> Provider id (default: anthropic)
528 --extension <path> Load a provider or OAuth extension explicitly.
529 --no-extensions Disable extension discovery and package auth declarations.
530 --json (check only) Emit a {{ready, provider, source}} JSON object
531
532Environment:
533 ANTHROPIC_API_KEY Fallback API key (x-api-key) when no stored credential.
534 ANTHROPIC_AUTH_TOKEN Fallback bearer token (Authorization: Bearer).
535 OPENAI_API_KEY Fallback bearer token for the built-in openai identity.
536 Other providers Environment names come from installed package declarations.
537
538Notes:
539 `login --provider <id>` uses the registered OAuth extension when one exists;
540 otherwise it is a provider-neutral API-key compatibility prompt. The host
541 only handles standard interaction and credential responses for OAuth.
542 `refresh` and `logout` use extension actions. API-key providers may still
543 use ~/.rpi/agent/models.json or environment variables.
544",
545 name = crate::APP_NAME
546 );
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::config::{
553 auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
554 };
555
556 struct TempConfig {
560 _guard: std::sync::MutexGuard<'static, ()>,
561 _tmp: tempfile::TempDir,
562 prev_dir: Option<std::ffi::OsString>,
563 prev_key: Option<std::ffi::OsString>,
564 prev_tok: Option<std::ffi::OsString>,
565 prev_openai_key: Option<std::ffi::OsString>,
566 }
567 impl TempConfig {
568 fn new() -> Self {
569 let guard = env_lock().lock().unwrap();
570 let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
571 let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
572 let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
573 let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
574 let tmp = tempfile::TempDir::new().unwrap();
575 std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
576 std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
577 std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
578 std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
579 Self {
580 _guard: guard,
581 _tmp: tmp,
582 prev_dir,
583 prev_key,
584 prev_tok,
585 prev_openai_key,
586 }
587 }
588 }
589 impl Drop for TempConfig {
590 fn drop(&mut self) {
591 restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
592 restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
593 restore(
594 crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
595 self.prev_tok.take(),
596 );
597 restore(
598 crate::provider::OPENAI_API_KEY_ENV,
599 self.prev_openai_key.take(),
600 );
601 }
602 }
603 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
604 match prev {
605 Some(v) => std::env::set_var(name, v),
606 None => std::env::remove_var(name),
607 }
608 }
609
610 #[test]
611 fn standard_oauth_credential_response_is_parsed() {
612 let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
613 "version": 1,
614 "kind": "credential",
615 "payload": {
616 "access": "access",
617 "refresh": "refresh",
618 "expires_at": 123
619 }
620 }))
621 .unwrap();
622 let credential = response.credential().unwrap().unwrap();
623 assert_eq!(credential.access, "access");
624 assert_eq!(credential.refresh, "refresh");
625 assert_eq!(credential.expires_at, 123);
626 }
627
628 #[tokio::test]
629 async fn check_not_ready_with_no_credentials() {
630 let _cfg = TempConfig::new();
631 let code = run_check(&[], None).await;
632 assert_eq!(code, EXIT_NOT_READY);
633 }
634
635 #[tokio::test]
636 async fn check_ready_with_stored_credential() {
637 let _cfg = TempConfig::new();
638 upsert_credential(
639 DEFAULT_PROVIDER_ID,
640 Credential::ApiKey {
641 key: Some("sk-stored".into()),
642 env: None,
643 },
644 )
645 .unwrap();
646 let code = run_check(&[], None).await;
647 assert_eq!(code, 0);
648 }
649
650 #[tokio::test]
651 async fn check_ready_with_oauth_credential() {
652 let _cfg = TempConfig::new();
653 crate::config::upsert_oauth_token(
654 DEFAULT_PROVIDER_ID,
655 "access".into(),
656 "refresh".into(),
657 0,
658 )
659 .unwrap();
660 assert_eq!(run_check(&[], None).await, 0);
661 }
662
663 #[tokio::test]
664 async fn check_rejects_expired_oauth_credential() {
665 let _cfg = TempConfig::new();
666 crate::config::upsert_oauth_token(
667 DEFAULT_PROVIDER_ID,
668 "access".into(),
669 "refresh".into(),
670 1,
671 )
672 .unwrap();
673 assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
674 }
675
676 #[tokio::test]
677 async fn check_ready_with_env_api_key() {
678 let _cfg = TempConfig::new();
679 std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
680 let code = run_check(&[], None).await;
681 assert_eq!(code, 0);
682 }
683
684 #[test]
685 fn check_does_not_borrow_credentials_from_another_provider_or_protocol() {
686 let _cfg = TempConfig::new();
687 std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "anthropic-key");
688 std::env::set_var(crate::provider::OPENAI_API_KEY_ENV, "openai-key");
689 std::fs::write(
690 config::models_path().unwrap(),
691 r#"{"providers":{
692 "gateway":{"api":"openai-completions","models":[{"id":"model"}]},
693 "another":{"api":"openai-completions","apiKey":"another-key","models":[{"id":"other"}]}
694 }}"#,
695 )
696 .unwrap();
697 for provider in ["gateway", "unknown", "openai-completions", "OpenAI"] {
698 assert_eq!(
699 detect_credential(provider, None).unwrap(),
700 CredentialSource::Missing,
701 "{provider}"
702 );
703 }
704 std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
706 assert_eq!(
707 detect_credential("openai", None).unwrap(),
708 CredentialSource::Missing
709 );
710 }
711
712 #[test]
713 fn check_resolves_stored_references_and_rejects_empty_or_unresolved_values() {
714 let _cfg = TempConfig::new();
715 upsert_credential(
716 "gateway",
717 Credential::ApiKey {
718 key: Some("$RPI_TEST_STORED_KEY".into()),
719 env: Some(std::collections::BTreeMap::from([(
720 "RPI_TEST_STORED_KEY".into(),
721 "stored-key".into(),
722 )])),
723 },
724 )
725 .unwrap();
726 assert_eq!(
727 detect_credential("gateway", None).unwrap(),
728 CredentialSource::StoredApiKey
729 );
730 for key in [Some("$RPI_TEST_AUTH_MISSING_VARIABLE"), Some(" "), None] {
731 upsert_credential(
732 "gateway",
733 Credential::ApiKey {
734 key: key.map(str::to_string),
735 env: None,
736 },
737 )
738 .unwrap();
739 assert_eq!(
740 detect_credential("gateway", None).unwrap(),
741 CredentialSource::Missing
742 );
743 }
744 }
745
746 #[tokio::test]
747 async fn check_reports_config_errors_and_respects_resolved_header_auth() {
748 let _cfg = TempConfig::new();
749 std::fs::write(
750 config::models_path().unwrap(),
751 r#"{"providers":{"gateway":{
752 "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":""}}]
753 }}}"#,
754 )
755 .unwrap();
756 assert_eq!(
757 detect_credential("gateway", None).unwrap(),
758 CredentialSource::Missing
759 );
760 std::fs::write(config::models_path().unwrap(), r#"{"providers":{"gateway":{
761 "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":"Bearer key"}}]
762 }}}"#).unwrap();
763 assert_eq!(
764 detect_credential("gateway", None).unwrap(),
765 CredentialSource::Headers
766 );
767 std::fs::write(config::auth_path().unwrap(), "{invalid").unwrap();
768 assert_eq!(run_check(&[], None).await, EXIT_ERROR);
769 }
770
771 #[tokio::test]
772 async fn check_ready_with_openai_models_json_key() {
773 let _cfg = TempConfig::new();
774 std::fs::write(
775 crate::config::models_path().unwrap(),
776 r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
777 )
778 .unwrap();
779 let code = run_check(&["--provider".to_string(), "gateway".to_string()], None).await;
780 assert_eq!(code, 0);
781 }
782
783 #[tokio::test]
784 async fn check_json_outputs_object() {
785 let _cfg = TempConfig::new();
786 upsert_credential(
789 DEFAULT_PROVIDER_ID,
790 Credential::ApiKey {
791 key: Some("sk-x".into()),
792 env: None,
793 },
794 )
795 .unwrap();
796 let code = run_check(&["--json".to_string()], None).await;
797 assert_eq!(code, 0);
798 }
799
800 #[tokio::test]
801 async fn logout_removes_stored_credential() {
802 let _cfg = TempConfig::new();
803 upsert_credential(
804 DEFAULT_PROVIDER_ID,
805 Credential::ApiKey {
806 key: Some("sk".into()),
807 env: None,
808 },
809 )
810 .unwrap();
811 assert!(auth_path().unwrap().exists());
812 let code = run_logout(&[], None).await;
813 assert_eq!(code, 0);
814 assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
816 }
817
818 #[tokio::test]
819 async fn logout_when_empty_is_noop() {
820 let _cfg = TempConfig::new();
821 let code = run_logout(&[], None).await;
822 assert_eq!(code, 0);
823 }
824
825 #[tokio::test]
826 async fn unknown_auth_subcommand_errors() {
827 let code = run(&["bogus".to_string()]).await;
828 assert_eq!(code, EXIT_ERROR);
829 }
830
831 #[tokio::test]
832 async fn auth_help_exits_zero() {
833 let code = run(&[]).await;
834 assert_eq!(code, 0);
835 let code = run(&["--help".to_string()]).await;
836 assert_eq!(code, 0);
837 }
838
839 #[test]
840 fn parse_provider_handles_both_forms() {
841 assert_eq!(parse_provider(&[]), None);
842 assert_eq!(
843 parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
844 Some("anthropic")
845 );
846 assert_eq!(
847 parse_provider(&["--provider=custom".to_string()]),
848 Some("custom")
849 );
850 assert_eq!(parse_provider(&["--provider".to_string()]), None);
852 }
853}