raqeem_core/credentials.rs
1//! Which key a backend is allowed to be given.
2//!
3//! This is one rule, and it is a security rule, so it lives in one place. The CLI and
4//! the Python binding both route through [`resolve_api_key`] rather than each deciding
5//! for itself — they used to decide separately, and a comment in the binding admitted
6//! it was "mirroring the CLI's credential rules exactly", which is the kind of promise
7//! that holds right up until it doesn't.
8//!
9//! Reading the environment stays with the caller. That keeps this function pure and its
10//! tests free of `set_var`, which is racy under a thread-parallel test runner and
11//! `unsafe` as of Rust 2024.
12
13use crate::provider::Provider;
14
15/// Pick the API key to send, or `None` to send no `Authorization` header at all.
16///
17/// - `explicit` — what the user handed us for *this* call: `--api-key`, the `api_key=`
18/// argument, or our own `$RAQEEM_API_KEY`. Applies to any backend, because it is
19/// scoped to raqeem rather than to a vendor.
20/// - `cohere_env` — `$COHERE_API_KEY`. Scoped to Cohere by its name, so it is a fallback
21/// for [`Provider::Cohere`] and nothing else.
22///
23/// The asymmetry is the whole point: a self-hosted or third-party endpoint reached via
24/// `--endpoint` must never be handed a Cohere key just because one happens to be
25/// exported. That would ship the user's credential to a server Cohere doesn't control.
26pub fn resolve_api_key(
27 provider: Provider,
28 explicit: Option<String>,
29 cohere_env: Option<String>,
30) -> Option<String> {
31 let (explicit, cohere_env) = (present(explicit), present(cohere_env));
32 match provider {
33 Provider::Cohere => explicit.or(cohere_env),
34 Provider::OpenAiCompatible => explicit,
35 }
36}
37
38/// A blank value is not a key.
39///
40/// `std::env::var` hands back `Ok("")` for a variable that is set but empty, which is an
41/// everyday state: a CI secret that didn't resolve, `export COHERE_API_KEY=` in a shell
42/// script, a `.env` line with nothing after the `=`. Treating that as a credential meant
43/// sending `Authorization: Bearer ` and getting an opaque 401 back from the server, in
44/// place of the clear local error one line further on.
45fn present(key: Option<String>) -> Option<String> {
46 key.filter(|k| !k.trim().is_empty())
47}
48
49#[cfg(test)]
50mod tests {
51 use super::resolve_api_key;
52 use crate::provider::Provider;
53
54 #[test]
55 fn cohere_falls_back_to_the_cohere_scoped_env() {
56 assert_eq!(
57 resolve_api_key(Provider::Cohere, None, Some("cohere-key".into())),
58 Some("cohere-key".into())
59 );
60 }
61
62 #[test]
63 fn an_explicit_key_wins_over_the_fallback() {
64 assert_eq!(
65 resolve_api_key(
66 Provider::Cohere,
67 Some("explicit".into()),
68 Some("cohere-key".into())
69 ),
70 Some("explicit".into())
71 );
72 }
73
74 /// The one that matters: a Cohere-scoped key must not leak to someone else's server.
75 #[test]
76 fn a_self_hosted_endpoint_never_receives_the_cohere_key() {
77 assert_eq!(
78 resolve_api_key(Provider::OpenAiCompatible, None, Some("cohere-key".into())),
79 None
80 );
81 }
82
83 #[test]
84 fn a_self_hosted_endpoint_still_honors_its_own_key() {
85 assert_eq!(
86 resolve_api_key(
87 Provider::OpenAiCompatible,
88 Some("mykey".into()),
89 Some("cohere-key".into())
90 ),
91 Some("mykey".into())
92 );
93 }
94
95 #[test]
96 fn a_blank_key_is_not_a_key() {
97 for blank in ["", " ", "\t", "\n"] {
98 assert_eq!(
99 resolve_api_key(Provider::Cohere, Some(blank.into()), None),
100 None,
101 "blank explicit key {blank:?} should not count"
102 );
103 assert_eq!(
104 resolve_api_key(Provider::Cohere, None, Some(blank.into())),
105 None,
106 "blank $COHERE_API_KEY {blank:?} should not count"
107 );
108 }
109 }
110
111 /// The case that actually bites: an empty `--api-key` or `$RAQEEM_API_KEY` must fall
112 /// through to a real `$COHERE_API_KEY` rather than shadowing it with nothing.
113 #[test]
114 fn a_blank_explicit_key_falls_through_to_the_env() {
115 assert_eq!(
116 resolve_api_key(Provider::Cohere, Some("".into()), Some("real-key".into())),
117 Some("real-key".into())
118 );
119 }
120
121 /// ...but it must not resurrect the Cohere key for a self-hosted endpoint.
122 #[test]
123 fn a_blank_explicit_key_does_not_leak_the_cohere_key_to_openai() {
124 assert_eq!(
125 resolve_api_key(
126 Provider::OpenAiCompatible,
127 Some("".into()),
128 Some("cohere-key".into())
129 ),
130 None
131 );
132 }
133
134 #[test]
135 fn no_key_anywhere_is_none_not_empty_string() {
136 assert_eq!(resolve_api_key(Provider::Cohere, None, None), None);
137 assert_eq!(
138 resolve_api_key(Provider::OpenAiCompatible, None, None),
139 None
140 );
141 }
142}