talon_core/config/
auth.rs1use std::collections::BTreeMap;
4use std::env;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::TalonError;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct CredentialEntry {
14 #[serde(default)]
16 pub api_key: Option<String>,
17 #[serde(default)]
19 pub api_key_env: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
24pub struct CredentialsConfig {
25 #[serde(flatten)]
26 pub entries: BTreeMap<String, CredentialEntry>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct ResolvedAuth {
32 pub api_key: Option<String>,
34 pub extra_headers: BTreeMap<String, String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(deny_unknown_fields)]
41pub struct EndpointAuthConfig {
42 #[serde(default)]
43 pub credential: Option<String>,
44 #[serde(default)]
45 pub api_key: Option<String>,
46 #[serde(default)]
47 pub api_key_env: Option<String>,
48 #[serde(default)]
49 pub extra_headers: BTreeMap<String, String>,
50}
51
52impl EndpointAuthConfig {
53 pub fn resolve(&self, credentials: &CredentialsConfig) -> Result<ResolvedAuth, TalonError> {
60 let api_key = resolve_api_key(credentials, self)?;
61 Ok(ResolvedAuth {
62 api_key,
63 extra_headers: self.extra_headers.clone(),
64 })
65 }
66}
67
68pub fn resolve_api_key(
78 credentials: &CredentialsConfig,
79 auth: &EndpointAuthConfig,
80) -> Result<Option<String>, TalonError> {
81 if let Some(key) = non_empty(auth.api_key.as_deref()) {
82 return Ok(Some(key.to_owned()));
83 }
84 if let Some(env_name) = non_empty(auth.api_key_env.as_deref()) {
85 return read_env_key(env_name);
86 }
87 let Some(credential_name) = non_empty(auth.credential.as_deref()) else {
88 return Ok(None);
89 };
90 let entry = credentials
91 .entries
92 .get(credential_name)
93 .ok_or_else(|| TalonError::Config {
94 message: format!("unknown credential: {credential_name}"),
95 })?;
96 if let Some(key) = non_empty(entry.api_key.as_deref()) {
97 return Ok(Some(key.to_owned()));
98 }
99 if let Some(env_name) = non_empty(entry.api_key_env.as_deref()) {
100 if let Some(key) = try_env_key(env_name)? {
102 return Ok(Some(key));
103 }
104 }
105 match crate::config::keychain::get(credential_name) {
106 Ok(Some(key)) => Ok(Some(key)),
107 Ok(None) => Ok(None),
108 Err(error) => {
109 tracing::debug!(%credential_name, %error, "failed to read credential from keychain");
110 Ok(None)
111 }
112 }
113}
114
115fn try_env_key(env_name: &str) -> Result<Option<String>, TalonError> {
118 match env::var(env_name) {
119 Ok(value) if value.is_empty() => Err(TalonError::Config {
120 message: format!("environment variable {env_name} is empty"),
121 }),
122 Ok(value) => Ok(Some(value)),
123 Err(env::VarError::NotPresent) => Ok(None),
124 Err(env::VarError::NotUnicode(_)) => Err(TalonError::Config {
125 message: format!("environment variable {env_name} is not valid UTF-8"),
126 }),
127 }
128}
129
130fn read_env_key(env_name: &str) -> Result<Option<String>, TalonError> {
131 match env::var(env_name) {
132 Ok(value) if value.is_empty() => Err(TalonError::Config {
133 message: format!("environment variable {env_name} is empty"),
134 }),
135 Ok(value) => Ok(Some(value)),
136 Err(env::VarError::NotPresent) => Err(TalonError::Config {
137 message: format!("environment variable {env_name} is not set"),
138 }),
139 Err(env::VarError::NotUnicode(_)) => Err(TalonError::Config {
140 message: format!("environment variable {env_name} is not valid UTF-8"),
141 }),
142 }
143}
144
145fn non_empty(value: Option<&str>) -> Option<&str> {
146 value.filter(|s| !s.is_empty())
147}
148
149#[cfg(test)]
150#[allow(clippy::expect_used, clippy::unwrap_used)]
151mod tests {
152 use super::*;
153
154 fn creds() -> CredentialsConfig {
155 let mut entries = BTreeMap::new();
156 entries.insert(
157 "openrouter".to_owned(),
158 CredentialEntry {
159 api_key: None,
160 api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
161 },
162 );
163 CredentialsConfig { entries }
164 }
165
166 #[test]
167 fn inline_api_key_wins() {
168 let auth = EndpointAuthConfig {
169 api_key: Some("inline".to_owned()),
170 api_key_env: Some("IGNORE".to_owned()),
171 ..EndpointAuthConfig::default()
172 };
173 assert_eq!(
174 resolve_api_key(&creds(), &auth).expect("resolve inline api key"),
175 Some("inline".to_owned())
176 );
177 }
178
179 #[test]
180 fn credential_entry_api_key_is_used_when_present() {
181 let mut entries = BTreeMap::new();
182 entries.insert(
183 "openrouter".to_string(),
184 CredentialEntry {
185 api_key: Some("from-table".to_owned()),
186 api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
187 },
188 );
189 let creds = CredentialsConfig { entries };
190 let auth = EndpointAuthConfig {
191 credential: Some("openrouter".to_owned()),
192 ..EndpointAuthConfig::default()
193 };
194 assert_eq!(
195 resolve_api_key(&creds, &auth).expect("resolve credential api key"),
196 Some("from-table".to_owned())
197 );
198 }
199}