1use std::path::{Path, PathBuf};
11
12use indexmap::IndexMap;
13use yapi_types::auth::ApiKeyCredential;
14
15use crate::auth::{AuthError, AuthEvent, AuthPrompt, Interaction, Link};
16use crate::credentials::ProviderEnv;
17
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct Resolved {
21 pub api_key: Option<String>,
23 pub headers: IndexMap<String, Option<String>>,
25 pub env: Option<ProviderEnv>,
27 pub source: String,
29 pub base_url: Option<String>,
31}
32
33pub struct Ambient<'a> {
35 pub env: &'a dyn Fn(&str) -> Option<String>,
37 pub file_exists: &'a dyn Fn(&str) -> bool,
39}
40
41impl Ambient<'static> {
42 pub fn process() -> Ambient<'static> {
44 Ambient {
45 env: &process_env,
46 file_exists: &file_exists,
47 }
48 }
49}
50
51fn process_env(name: &str) -> Option<String> {
52 std::env::var(name)
53 .ok()
54 .filter(|value| !value.trim().is_empty())
55}
56
57fn file_exists(path: &str) -> bool {
58 let path = match path.strip_prefix('~') {
59 Some(rest) => match std::env::var_os("HOME")
61 .map(PathBuf::from)
62 .or_else(std::env::home_dir)
63 {
64 Some(home) => PathBuf::from(format!("{}{rest}", home.to_string_lossy())),
65 None => return false,
66 },
67 None => PathBuf::from(path),
68 };
69 Path::new(&path).exists()
70}
71
72pub fn is_custom(provider: &str) -> bool {
75 matches!(
76 provider,
77 "amazon-bedrock"
78 | "google-vertex"
79 | "cloudflare-workers-ai"
80 | "cloudflare-ai-gateway"
81 | crate::llama::PROVIDER_ID
82 )
83}
84
85pub fn resolve(
89 provider: &str,
90 credential: Option<&ApiKeyCredential>,
91 ambient: &Ambient<'_>,
92) -> Option<Resolved> {
93 match provider {
94 "amazon-bedrock" => bedrock(credential, ambient),
95 "google-vertex" => vertex(credential, ambient),
96 "cloudflare-workers-ai" => cloudflare(false, credential, ambient),
97 "cloudflare-ai-gateway" => cloudflare(true, credential, ambient),
98 crate::llama::PROVIDER_ID => llama(credential, ambient),
99 _ => None,
100 }
101}
102
103fn llama(credential: Option<&ApiKeyCredential>, ambient: &Ambient<'_>) -> Option<Resolved> {
106 use crate::llama::{BASE_URL_ENV, inference_url, normalize_server_url};
107 let configured = stored_env(credential, BASE_URL_ENV)
108 .filter(|url| !url.trim().is_empty())
109 .or_else(|| (ambient.env)(BASE_URL_ENV))?;
110 let server = normalize_server_url(&configured).ok()?;
111 let api_key = credential
112 .and_then(|credential| credential.key.clone())
113 .or_else(|| (ambient.env)("LLAMA_API_KEY"))
114 .unwrap_or_else(|| "local".into());
115 let mut env = credential
116 .and_then(|credential| credential.env.clone())
117 .unwrap_or_default();
118 env.insert(BASE_URL_ENV.into(), server.clone());
119 Some(Resolved {
120 api_key: Some(api_key),
121 headers: IndexMap::new(),
122 env: Some(env),
123 source: if credential.is_some() {
124 "stored credential"
125 } else {
126 BASE_URL_ENV
127 }
128 .into(),
129 base_url: Some(inference_url(&server)),
130 })
131}
132
133fn stored_key(credential: Option<&ApiKeyCredential>) -> Option<&str> {
134 credential
135 .and_then(|credential| credential.key.as_deref())
136 .filter(|key| !key.is_empty())
137}
138
139fn stored_env(credential: Option<&ApiKeyCredential>, name: &str) -> Option<String> {
140 credential
141 .and_then(|credential| credential.env.as_ref())
142 .and_then(|env| env.get(name))
143 .cloned()
144}
145
146fn bedrock(credential: Option<&ApiKeyCredential>, ambient: &Ambient<'_>) -> Option<Resolved> {
147 let env = |name: &str| (ambient.env)(name);
148 let resolved = |source: &str, env: Option<ProviderEnv>| Resolved {
149 source: source.to_owned(),
150 env,
151 ..Resolved::default()
152 };
153 if let Some(key) = stored_key(credential) {
154 return Some(Resolved {
155 api_key: Some(key.to_owned()),
156 ..resolved("stored credential", credential.and_then(|c| c.env.clone()))
157 });
158 }
159 if env("AWS_BEARER_TOKEN_BEDROCK").is_some() {
160 return Some(resolved("AWS_BEARER_TOKEN_BEDROCK", None));
161 }
162 let stored_profile = stored_env(credential, "AWS_PROFILE").filter(|p| !p.is_empty());
163 if stored_profile.is_some() || env("AWS_PROFILE").is_some() {
164 let source = if stored_profile.is_some() {
165 "stored credential"
166 } else {
167 "AWS_PROFILE"
168 };
169 return Some(resolved(source, credential.and_then(|c| c.env.clone())));
170 }
171 if env("AWS_ACCESS_KEY_ID").is_some() && env("AWS_SECRET_ACCESS_KEY").is_some() {
172 return Some(resolved("AWS access keys", None));
173 }
174 if env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some()
175 || env("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some()
176 {
177 return Some(resolved("ECS task role", None));
178 }
179 if env("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() {
180 return Some(resolved("web identity token", None));
181 }
182 None
183}
184
185const VERTEX_ADC_PATH: &str = "~/.config/gcloud/application_default_credentials.json";
187
188fn vertex(credential: Option<&ApiKeyCredential>, ambient: &Ambient<'_>) -> Option<Resolved> {
189 let env = |name: &str| (ambient.env)(name);
190 let (key, source) = match credential.and_then(|c| c.key.clone()) {
192 Some(key) => (key, "stored credential"),
193 None => (
194 env("GOOGLE_CLOUD_API_KEY").unwrap_or_default(),
195 "GOOGLE_CLOUD_API_KEY",
196 ),
197 };
198 if !key.is_empty() {
199 return Some(Resolved {
200 api_key: Some(key),
201 source: source.into(),
202 ..Resolved::default()
203 });
204 }
205 let adc = stored_env(credential, "GOOGLE_APPLICATION_CREDENTIALS")
206 .or_else(|| env("GOOGLE_APPLICATION_CREDENTIALS"));
207 let has_credentials = (ambient.file_exists)(adc.as_deref().unwrap_or(VERTEX_ADC_PATH));
208 let project = stored_env(credential, "GOOGLE_CLOUD_PROJECT")
209 .or_else(|| env("GOOGLE_CLOUD_PROJECT"))
210 .or_else(|| env("GCLOUD_PROJECT"));
211 let location =
212 stored_env(credential, "GOOGLE_CLOUD_LOCATION").or_else(|| env("GOOGLE_CLOUD_LOCATION"));
213 let configured = |value: &Option<String>| value.as_deref().is_some_and(|v| !v.is_empty());
214 (has_credentials && configured(&project) && configured(&location)).then(|| Resolved {
215 env: credential.and_then(|c| c.env.clone()),
216 source: if credential.is_some() {
217 "stored credential"
218 } else {
219 "gcloud application default credentials"
220 }
221 .into(),
222 ..Resolved::default()
223 })
224}
225
226const CLOUDFLARE_API_KEY: &str = "CLOUDFLARE_API_KEY";
227const CLOUDFLARE_ACCOUNT_ID: &str = "CLOUDFLARE_ACCOUNT_ID";
228const CLOUDFLARE_GATEWAY_ID: &str = "CLOUDFLARE_GATEWAY_ID";
229
230fn cloudflare(
231 gateway: bool,
232 credential: Option<&ApiKeyCredential>,
233 ambient: &Ambient<'_>,
234) -> Option<Resolved> {
235 let value = |name: &str| {
238 let stored = credential.and_then(|credential| {
239 if name == CLOUDFLARE_API_KEY {
240 credential.key.clone()
241 } else {
242 credential.env.as_ref()?.get(name).cloned()
243 }
244 });
245 stored.or_else(|| (ambient.env)(name))
246 };
247 let api_key = value(CLOUDFLARE_API_KEY).filter(|v| !v.is_empty())?;
248 let account = value(CLOUDFLARE_ACCOUNT_ID).filter(|v| !v.is_empty())?;
249 let mut env = ProviderEnv::new();
250 env.insert(CLOUDFLARE_ACCOUNT_ID.into(), account);
251 let source = if credential.is_some() {
252 "stored credential"
253 } else {
254 CLOUDFLARE_API_KEY
255 }
256 .to_owned();
257 if !gateway {
258 return Some(Resolved {
259 api_key: Some(api_key),
260 headers: IndexMap::new(),
261 env: Some(env),
262 source,
263 base_url: None,
264 });
265 }
266 let gateway_id = value(CLOUDFLARE_GATEWAY_ID).filter(|v| !v.is_empty())?;
267 env.insert(CLOUDFLARE_GATEWAY_ID.into(), gateway_id);
268 let mut headers = IndexMap::new();
269 headers.insert(
270 "cf-aig-authorization".to_owned(),
271 Some(format!("Bearer {api_key}")),
272 );
273 headers.insert("Authorization".to_owned(), None);
274 headers.insert("x-api-key".to_owned(), None);
275 Some(Resolved {
276 api_key: None,
277 headers,
278 env: Some(env),
279 source,
280 base_url: None,
281 })
282}
283
284const FEDERATION_REQUIRED: [&str; 3] = [
287 "ANTHROPIC_FEDERATION_RULE_ID",
288 "ANTHROPIC_ORGANIZATION_ID",
289 "ANTHROPIC_IDENTITY_TOKEN_FILE",
290];
291const FEDERATION_OPTIONAL: [&str; 2] = ["ANTHROPIC_SERVICE_ACCOUNT_ID", "ANTHROPIC_WORKSPACE_ID"];
292
293pub fn anthropic_federation(ambient: &Ambient<'_>) -> Option<Resolved> {
296 let mut env = ProviderEnv::new();
297 for name in FEDERATION_REQUIRED {
298 env.insert(name.to_owned(), (ambient.env)(name)?);
299 }
300 for name in FEDERATION_OPTIONAL {
301 if let Some(value) = (ambient.env)(name) {
302 env.insert(name.to_owned(), value);
303 }
304 }
305 Some(Resolved {
306 env: Some(env),
307 source: "workload identity federation".into(),
308 ..Resolved::default()
309 })
310}
311
312fn text(message: &str) -> AuthPrompt {
313 AuthPrompt::Text {
314 message: message.to_owned(),
315 placeholder: None,
316 }
317}
318
319fn secret(message: &str) -> AuthPrompt {
320 AuthPrompt::Secret {
321 message: message.to_owned(),
322 }
323}
324
325fn info(interaction: &Interaction, message: &str, label: &str, url: &str) {
326 interaction.notify(AuthEvent::Info {
327 message: message.to_owned(),
328 links: vec![Link {
329 label: label.to_owned(),
330 url: url.to_owned(),
331 }],
332 });
333}
334
335fn env_of(pairs: &[(&str, String)]) -> Option<ProviderEnv> {
336 Some(
337 pairs
338 .iter()
339 .map(|(name, value)| ((*name).to_owned(), value.clone()))
340 .collect(),
341 )
342}
343
344pub async fn login(
347 provider: &str,
348 interaction: &Interaction,
349) -> Option<Result<ApiKeyCredential, AuthError>> {
350 Some(match provider {
351 "amazon-bedrock" => bedrock_login(interaction).await,
352 "google-vertex" => vertex_login(interaction).await,
353 "cloudflare-workers-ai" => cloudflare_login(false, interaction).await,
354 "cloudflare-ai-gateway" => cloudflare_login(true, interaction).await,
355 crate::llama::PROVIDER_ID => llama_login(interaction).await,
356 _ => return None,
357 })
358}
359
360async fn llama_login(interaction: &Interaction) -> Result<ApiKeyCredential, AuthError> {
363 use crate::llama::{BASE_URL_ENV, Client, DEFAULT_SERVER_URL, normalize_server_url};
364 interaction.check()?;
365 let fallback = process_env(BASE_URL_ENV).unwrap_or_else(|| DEFAULT_SERVER_URL.into());
366 let entered = interaction
367 .prompt(AuthPrompt::Text {
368 message: "llama.cpp server URL".into(),
369 placeholder: Some(fallback.clone()),
370 })
371 .await?;
372 let entered = entered.trim();
373 let server = normalize_server_url(if entered.is_empty() {
374 &fallback
375 } else {
376 entered
377 })
378 .map_err(AuthError::Failed)?;
379 interaction.check()?;
380 let key = interaction
381 .prompt(secret("API key (optional)"))
382 .await?
383 .trim()
384 .to_owned();
385 let key = (!key.is_empty()).then_some(key);
386 Client::new(&server, key.clone())
387 .map_err(AuthError::Failed)?
388 .list(false, interaction.cancel())
389 .await
390 .map_err(AuthError::Failed)?;
391 Ok(ApiKeyCredential {
392 key,
393 env: env_of(&[(BASE_URL_ENV, server)]),
394 })
395}
396
397async fn bedrock_login(interaction: &Interaction) -> Result<ApiKeyCredential, AuthError> {
398 interaction.check()?;
399 let method = interaction
400 .prompt(AuthPrompt::select(
401 "Select Amazon Bedrock authentication method:",
402 &[
403 ("bearer-token", "Bearer token"),
404 ("aws-profile", "AWS profile"),
405 ("credential-chain", "Existing AWS credential chain"),
406 ],
407 ))
408 .await?;
409 interaction.check()?;
410 if method == "bearer-token" {
411 let key = interaction
412 .prompt(secret("Enter Amazon Bedrock bearer token"))
413 .await?;
414 return Ok(ApiKeyCredential {
415 key: Some(key),
416 env: None,
417 });
418 }
419 info(
420 interaction,
421 "Amazon Bedrock supports AWS profiles, IAM credentials, and role-based credentials.",
422 "AWS credential provider chain",
423 "https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html",
424 );
425 match method.as_str() {
426 "aws-profile" => {
427 let profile = interaction.prompt(text("Enter AWS profile name")).await?;
428 Ok(ApiKeyCredential {
429 key: None,
430 env: env_of(&[("AWS_PROFILE", profile)]),
431 })
432 }
433 "credential-chain" => {
434 interaction
435 .prompt(text(
436 "Configure AWS credentials, then press Enter to continue",
437 ))
438 .await?;
439 Ok(ApiKeyCredential {
440 key: None,
441 env: None,
442 })
443 }
444 other => Err(AuthError::failed(format!(
445 "Unknown Amazon Bedrock auth method: {other}"
446 ))),
447 }
448}
449
450async fn vertex_login(interaction: &Interaction) -> Result<ApiKeyCredential, AuthError> {
451 interaction.check()?;
452 let method = interaction
453 .prompt(AuthPrompt::select(
454 "Select Google Vertex AI authentication method:",
455 &[
456 ("api-key", "Google Cloud API key"),
457 ("adc", "Application Default Credentials"),
458 ("service-account", "Service account credentials file"),
459 ],
460 ))
461 .await?;
462 interaction.check()?;
463 if method == "api-key" {
464 let key = interaction
465 .prompt(secret("Enter Google Cloud API key"))
466 .await?;
467 return Ok(ApiKeyCredential {
468 key: Some(key),
469 env: None,
470 });
471 }
472 if method != "adc" && method != "service-account" {
473 return Err(AuthError::failed(format!(
474 "Unknown Google Vertex AI auth method: {method}"
475 )));
476 }
477 let message = if method == "adc" {
478 "Run `gcloud auth application-default login`, then provide the project and location."
479 } else {
480 "Provide a service account credentials file, project, and location."
481 };
482 info(
483 interaction,
484 message,
485 "Application Default Credentials",
486 "https://cloud.google.com/docs/authentication/provide-credentials-adc",
487 );
488 let project = interaction
489 .prompt(text("Enter Google Cloud project ID"))
490 .await?;
491 let location = interaction
492 .prompt(text("Enter Google Cloud location"))
493 .await?;
494 let mut env = vec![
495 ("GOOGLE_CLOUD_PROJECT", project),
496 ("GOOGLE_CLOUD_LOCATION", location),
497 ];
498 if method == "service-account" {
499 let path = interaction
500 .prompt(text("Enter service account credentials file path"))
501 .await?;
502 if !path.is_empty() {
504 env.push(("GOOGLE_APPLICATION_CREDENTIALS", path));
505 }
506 }
507 Ok(ApiKeyCredential {
508 key: None,
509 env: env_of(&env),
510 })
511}
512
513async fn cloudflare_login(
514 gateway: bool,
515 interaction: &Interaction,
516) -> Result<ApiKeyCredential, AuthError> {
517 let key = interaction
518 .prompt(secret("Enter Cloudflare API key"))
519 .await?;
520 let account = interaction
521 .prompt(text("Enter Cloudflare account ID"))
522 .await?;
523 let mut env = vec![(CLOUDFLARE_ACCOUNT_ID, account)];
524 if gateway {
525 let id = interaction
526 .prompt(text("Enter Cloudflare AI Gateway ID"))
527 .await?;
528 env.push((CLOUDFLARE_GATEWAY_ID, id));
529 }
530 Ok(ApiKeyCredential {
531 key: Some(key),
532 env: env_of(&env),
533 })
534}
535
536pub fn cloudflare_base_url(base_url: &str, env: Option<&ProviderEnv>) -> String {
540 let Some(env) = env else {
541 return base_url.to_owned();
542 };
543 let mut url = base_url.to_owned();
544 for name in [CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID] {
545 if let Some(value) = env.get(name) {
546 url = url.replace(&format!("{{{name}}}"), value);
547 }
548 }
549 url
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn ambient<'a>(
557 vars: &'a [(&'a str, &'a str)],
558 files: &'a [&'a str],
559 ) -> (
560 impl Fn(&str) -> Option<String> + 'a,
561 impl Fn(&str) -> bool + 'a,
562 ) {
563 (
564 move |name: &str| {
565 vars.iter()
566 .find(|(key, _)| *key == name)
567 .map(|(_, value)| (*value).to_owned())
568 },
569 move |path: &str| files.contains(&path),
570 )
571 }
572
573 fn stored(key: Option<&str>, env: &[(&str, &str)]) -> ApiKeyCredential {
574 ApiKeyCredential {
575 key: key.map(str::to_owned),
576 env: (!env.is_empty()).then(|| {
577 env.iter()
578 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
579 .collect()
580 }),
581 }
582 }
583
584 fn run(
585 provider: &str,
586 credential: Option<&ApiKeyCredential>,
587 vars: &[(&str, &str)],
588 files: &[&str],
589 ) -> Option<Resolved> {
590 let (env, exists) = ambient(vars, files);
591 resolve(
592 provider,
593 credential,
594 &Ambient {
595 env: &env,
596 file_exists: &exists,
597 },
598 )
599 }
600
601 #[test]
602 fn llama_server_and_key() {
603 assert_eq!(run("llama.cpp", None, &[], &[]), None);
604 let from_env = run(
605 "llama.cpp",
606 None,
607 &[("LLAMA_BASE_URL", "http://box:8080/v1/")],
608 &[],
609 )
610 .unwrap();
611 assert_eq!(from_env.source, "LLAMA_BASE_URL");
612 assert_eq!(from_env.api_key.as_deref(), Some("local"));
613 assert_eq!(from_env.base_url.as_deref(), Some("http://box:8080/v1"));
614 let credential = stored(Some("k"), &[("LLAMA_BASE_URL", "http://h:1")]);
615 let stored = run(
616 "llama.cpp",
617 Some(&credential),
618 &[
619 ("LLAMA_BASE_URL", "http://other:2"),
620 ("LLAMA_API_KEY", "env"),
621 ],
622 &[],
623 )
624 .unwrap();
625 assert_eq!(stored.source, "stored credential");
626 assert_eq!(stored.api_key.as_deref(), Some("k"));
627 assert_eq!(stored.base_url.as_deref(), Some("http://h:1/v1"));
628 assert_eq!(stored.env.unwrap()["LLAMA_BASE_URL"], "http://h:1");
629 }
630
631 #[test]
632 fn bedrock_follows_pi_order() {
633 let source = |credential: Option<&ApiKeyCredential>, vars: &[(&str, &str)]| {
634 run("amazon-bedrock", credential, vars, &[]).map(|r| r.source)
635 };
636 let token = stored(Some("tok"), &[("AWS_REGION", "eu-west-1")]);
637 let resolved = run("amazon-bedrock", Some(&token), &[], &[]).unwrap();
638 assert_eq!(resolved.api_key.as_deref(), Some("tok"));
639 assert_eq!(resolved.env.unwrap()["AWS_REGION"], "eu-west-1");
640 assert_eq!(
641 source(
642 None,
643 &[("AWS_BEARER_TOKEN_BEDROCK", "b"), ("AWS_PROFILE", "p")]
644 )
645 .as_deref(),
646 Some("AWS_BEARER_TOKEN_BEDROCK")
647 );
648 let profile = stored(None, &[("AWS_PROFILE", "dev")]);
649 let resolved = run("amazon-bedrock", Some(&profile), &[], &[]).unwrap();
650 assert_eq!(resolved.source, "stored credential");
651 assert_eq!(resolved.api_key, None);
652 assert_eq!(resolved.env.unwrap()["AWS_PROFILE"], "dev");
653 assert_eq!(
654 source(None, &[("AWS_ACCESS_KEY_ID", "a")]),
655 None,
656 "a key id needs its secret"
657 );
658 assert_eq!(
659 source(
660 None,
661 &[("AWS_ACCESS_KEY_ID", "a"), ("AWS_SECRET_ACCESS_KEY", "s")]
662 )
663 .as_deref(),
664 Some("AWS access keys")
665 );
666 assert_eq!(
667 source(None, &[("AWS_CONTAINER_CREDENTIALS_FULL_URI", "u")]).as_deref(),
668 Some("ECS task role")
669 );
670 assert_eq!(
671 source(None, &[("AWS_WEB_IDENTITY_TOKEN_FILE", "f")]).as_deref(),
672 Some("web identity token")
673 );
674 let chain = stored(None, &[]);
677 assert_eq!(
678 source(Some(&chain), &[("AWS_PROFILE", "p")]).as_deref(),
679 Some("AWS_PROFILE")
680 );
681 }
682
683 #[test]
684 fn vertex_required_fields() {
685 let full = [
686 ("GOOGLE_CLOUD_PROJECT", "p"),
687 ("GOOGLE_CLOUD_LOCATION", "us-central1"),
688 ];
689 assert_eq!(
690 run("google-vertex", None, &[("GOOGLE_CLOUD_API_KEY", "k")], &[])
691 .unwrap()
692 .api_key
693 .as_deref(),
694 Some("k")
695 );
696 assert!(run("google-vertex", None, &full, &[]).is_none());
697 let adc = run("google-vertex", None, &full, &[VERTEX_ADC_PATH]).unwrap();
698 assert_eq!(adc.source, "gcloud application default credentials");
699 assert_eq!(adc.api_key, None);
700 assert!(
701 run(
702 "google-vertex",
703 None,
704 &[("GCLOUD_PROJECT", "p")],
705 &[VERTEX_ADC_PATH]
706 )
707 .is_none()
708 );
709 let service = stored(
710 None,
711 &[
712 ("GOOGLE_APPLICATION_CREDENTIALS", "/sa.json"),
713 ("GOOGLE_CLOUD_PROJECT", "p"),
714 ("GOOGLE_CLOUD_LOCATION", "global"),
715 ],
716 );
717 let resolved = run("google-vertex", Some(&service), &[], &["/sa.json"]).unwrap();
718 assert_eq!(resolved.source, "stored credential");
719 assert_eq!(resolved.env.unwrap()["GOOGLE_CLOUD_LOCATION"], "global");
720 }
721
722 #[test]
723 fn cloudflare_gateway_key_header() {
724 let partial = stored(Some("cf"), &[]);
725 assert!(run("cloudflare-workers-ai", Some(&partial), &[], &[]).is_none());
726 let workers = run(
727 "cloudflare-workers-ai",
728 Some(&partial),
729 &[("CLOUDFLARE_ACCOUNT_ID", "acct")],
730 &[],
731 )
732 .unwrap();
733 assert_eq!(workers.api_key.as_deref(), Some("cf"));
734 assert_eq!(workers.env.unwrap()["CLOUDFLARE_ACCOUNT_ID"], "acct");
735 assert_eq!(workers.source, "stored credential");
736 let vars = [
737 ("CLOUDFLARE_API_KEY", "env-key"),
738 ("CLOUDFLARE_ACCOUNT_ID", "acct"),
739 ];
740 assert!(run("cloudflare-ai-gateway", None, &vars, &[]).is_none());
741 let gateway = run(
742 "cloudflare-ai-gateway",
743 None,
744 &[
745 ("CLOUDFLARE_API_KEY", "env-key"),
746 ("CLOUDFLARE_ACCOUNT_ID", "acct"),
747 ("CLOUDFLARE_GATEWAY_ID", "gw"),
748 ],
749 &[],
750 )
751 .unwrap();
752 assert_eq!(gateway.api_key, None);
753 assert_eq!(gateway.source, "CLOUDFLARE_API_KEY");
754 assert_eq!(
755 gateway.headers.get("cf-aig-authorization"),
756 Some(&Some("Bearer env-key".to_owned()))
757 );
758 assert_eq!(gateway.headers.get("Authorization"), Some(&None));
759 let env = gateway.env.unwrap();
760 assert_eq!(
761 cloudflare_base_url(
762 "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
763 Some(&env)
764 ),
765 "https://gateway.ai.cloudflare.com/v1/acct/gw/openai"
766 );
767 }
768
769 #[test]
770 fn federation_needs_three_ids() {
771 let (env, exists) = ambient(
772 &[
773 ("ANTHROPIC_FEDERATION_RULE_ID", "rule"),
774 ("ANTHROPIC_ORGANIZATION_ID", "org"),
775 ("ANTHROPIC_IDENTITY_TOKEN_FILE", "/token"),
776 ("ANTHROPIC_WORKSPACE_ID", "ws"),
777 ],
778 &[],
779 );
780 let resolved = anthropic_federation(&Ambient {
781 env: &env,
782 file_exists: &exists,
783 })
784 .unwrap();
785 let env = resolved.env.unwrap();
786 assert_eq!(env.len(), 4);
787 assert_eq!(env["ANTHROPIC_WORKSPACE_ID"], "ws");
788 let (env, exists) = ambient(&[("ANTHROPIC_FEDERATION_RULE_ID", "rule")], &[]);
789 assert!(
790 anthropic_federation(&Ambient {
791 env: &env,
792 file_exists: &exists
793 })
794 .is_none()
795 );
796 }
797
798 #[tokio::test]
799 async fn logins_ask_pi_questions() {
800 use crate::auth::AuthRequest;
801 use tokio_util::sync::CancellationToken;
802 let answers = [
803 (
804 "Select Google Vertex AI authentication method:",
805 "service-account",
806 ),
807 ("Enter Google Cloud project ID", "proj"),
808 ("Enter Google Cloud location", "us-east5"),
809 ("Enter service account credentials file path", "/sa.json"),
810 ];
811 let (interaction, mut requests) = Interaction::new(CancellationToken::new());
812 let answering = tokio::spawn(async move {
813 let mut asked = Vec::new();
814 while let Some(request) = requests.recv().await {
815 if let AuthRequest::Prompt { prompt, reply, .. } = request {
816 let message = match &prompt {
817 AuthPrompt::Select { message, .. }
818 | AuthPrompt::Text { message, .. }
819 | AuthPrompt::Secret { message }
820 | AuthPrompt::ManualCode { message, .. } => message.clone(),
821 };
822 let answer = answers
823 .iter()
824 .find(|(question, _)| *question == message)
825 .map(|(_, answer)| *answer)
826 .unwrap();
827 asked.push(message);
828 reply.send(answer.to_owned()).unwrap();
829 }
830 }
831 asked
832 });
833 let credential = login("google-vertex", &interaction).await.unwrap().unwrap();
834 drop(interaction);
835 assert_eq!(answering.await.unwrap().len(), 4);
836 assert_eq!(credential.key, None);
837 let env = credential.env.unwrap();
838 assert_eq!(
839 env.keys().collect::<Vec<_>>(),
840 [
841 "GOOGLE_CLOUD_PROJECT",
842 "GOOGLE_CLOUD_LOCATION",
843 "GOOGLE_APPLICATION_CREDENTIALS"
844 ]
845 );
846 assert!(
847 login("openai", &Interaction::new(CancellationToken::new()).0)
848 .await
849 .is_none()
850 );
851 }
852}