Skip to main content

yapi_ai/
key_auth.rs

1//! API-key credentials of the providers whose setup is more than one key in
2//! one environment variable: Amazon Bedrock, Google Vertex AI, the two
3//! Cloudflare providers, llama.cpp, and Anthropic's workload identity
4//! federation.
5//!
6//! Ports of the `ApiKeyAuth` objects in `providers/amazon-bedrock.ts`,
7//! `google-vertex.ts`, `cloudflare-auth.ts` and `anthropic.ts` in pi-ai, and
8//! of `extensions/llama/provider.ts` in pi-coding-agent, `v1.0.0`.
9
10use 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/// Request credentials from a stored credential or the environment.
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct Resolved {
21    /// Sent as the API key.
22    pub api_key: Option<String>,
23    /// Headers that carry the credential. `None` removes a header.
24    pub headers: IndexMap<String, Option<String>>,
25    /// Provider settings the request reads, such as a Cloudflare account id.
26    pub env: Option<ProviderEnv>,
27    /// Where the credential came from, as pi labels it.
28    pub source: String,
29    /// Replaces the model's base URL, as a llama.cpp server URL does.
30    pub base_url: Option<String>,
31}
32
33/// What resolution reads besides the stored credential.
34pub struct Ambient<'a> {
35    /// An environment variable. Blank values count as unset.
36    pub env: &'a dyn Fn(&str) -> Option<String>,
37    /// Whether a file exists. A leading `~` is the home directory.
38    pub file_exists: &'a dyn Fn(&str) -> bool,
39}
40
41impl Ambient<'static> {
42    /// The process environment and file system.
43    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        // Node's `os.homedir()`: `HOME`, even when empty, else the user database.
60        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
72/// Whether `provider`'s key auth is resolved here rather than from its key
73/// variables.
74pub 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
85/// pi's `resolve` for a custom provider: from `credential`, whose key is
86/// already resolved, else the environment. `None` when the provider is not
87/// configured or is not custom.
88pub 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
103/// llama.cpp: the server URL from the credential, else `LLAMA_BASE_URL`,
104/// and the key from the credential, else `LLAMA_API_KEY`, else `local`.
105fn 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
185/// gcloud's Application Default Credentials file.
186const 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    // A stored key, even an empty one, stands in for the variable.
191    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    // Per field: the credential's value, else the environment, so a stored
236    // key still picks up the account and gateway ids from the environment.
237    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
284/// The variables of Anthropic's workload identity federation: the three it
285/// needs, then the two passed through when set.
286const 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
293/// Anthropic's last resort when no key or token is set: workload identity
294/// federation, whose ids pass to the request as provider settings.
295pub 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
344/// pi's `login` for a custom provider: the questions that build its stored
345/// credential. `None` when the provider is not custom.
346pub 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
360/// pi's llama.cpp sign-in: the server URL and an optional key, checked by
361/// listing the server's models.
362async 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        // pi spreads the path only when it is non-empty.
503        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
536/// Replaces Cloudflare's `{CLOUDFLARE_ACCOUNT_ID}` and
537/// `{CLOUDFLARE_GATEWAY_ID}` placeholders in a base URL with the request's
538/// provider settings. Port of `resolveCloudflareModel`.
539pub 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        // An empty stored credential, from the credential chain login, still
675        // resolves from the environment.
676        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}