use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use fabro_model::{Catalog, ProviderId}; use crate::credential_source::{CredentialSource, ResolvedCredentials}; /// Decorates another [`CredentialSource`] by appending fixed extra headers to /// every credential it resolves. /// /// Headers already present on a credential (for example from explicit /// provider configuration) are left untouched. pub struct ExtraHeadersCredentialSource { inner: Arc, headers: HashMap, } impl ExtraHeadersCredentialSource { #[must_use] pub fn new(inner: Arc, headers: HashMap) -> Self { Self { inner, headers } } } #[async_trait] impl CredentialSource for ExtraHeadersCredentialSource { async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { let mut resolved = self.inner.resolve(catalog).await?; for credential in &mut resolved.credentials { for (name, value) in &self.headers { if credential .extra_headers .keys() .any(|existing| existing.eq_ignore_ascii_case(name)) { continue; } credential.extra_headers.insert(name.clone(), value.clone()); } } Ok(resolved) } async fn configured_providers(&self, catalog: &Catalog) -> Vec { self.inner.configured_providers(catalog).await } } #[cfg(test)] mod tests { use super::*; use crate::{ApiCredential, ResolveError}; struct StubSource { credentials: Vec, auth_issue_provider: Option, configured_providers: Vec, } #[async_trait] impl CredentialSource for StubSource { async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result { Ok(ResolvedCredentials { credentials: self.credentials.clone(), auth_issues: self .auth_issue_provider .iter() .map(|provider| { ( provider.clone(), ResolveError::RefreshTokenMissing(provider.clone()), ) }) .collect(), }) } async fn configured_providers(&self, _catalog: &Catalog) -> Vec { self.configured_providers.clone() } } fn credential(provider: ProviderId, extra_headers: HashMap) -> ApiCredential { ApiCredential { provider, auth_header: None, extra_headers, base_url: None, codex_mode: false, org_id: None, project_id: None, } } #[tokio::test] async fn appends_headers_to_every_resolved_credential() { let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { credentials: vec![ credential(ProviderId::anthropic(), HashMap::new()), credential(ProviderId::openai(), HashMap::new()), ], auth_issue_provider: None, configured_providers: Vec::new(), }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); let resolved = source.resolve(Catalog::builtin()).await.unwrap(); assert_eq!(resolved.credentials.len(), 2); for credential in &resolved.credentials { assert_eq!( credential .extra_headers .get("x-session-id") .map(String::as_str), Some("run-123") ); } } #[tokio::test] async fn preserves_case_insensitive_headers_already_set_on_a_credential() { let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { credentials: vec![credential( ProviderId::new("openrouter"), HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]), )], auth_issue_provider: None, configured_providers: Vec::new(), }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); let resolved = source.resolve(Catalog::builtin()).await.unwrap(); assert_eq!( resolved.credentials[0] .extra_headers .get("X-Session-Id") .map(String::as_str), Some("configured") ); assert_eq!(resolved.credentials[0].extra_headers.len(), 1); } #[tokio::test] async fn passes_through_auth_issues_and_configured_providers() { let auth_issue_provider = ProviderId::anthropic(); let configured_provider = ProviderId::gemini(); let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { credentials: vec![credential(ProviderId::openai(), HashMap::new())], auth_issue_provider: Some(auth_issue_provider.clone()), configured_providers: vec![configured_provider.clone()], }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); let resolved = source.resolve(Catalog::builtin()).await.unwrap(); let [(reported_provider, ResolveError::RefreshTokenMissing(error_provider))] = resolved.auth_issues.as_slice() else { panic!("expected the inner source's refresh-token issue"); }; assert_eq!(reported_provider, &auth_issue_provider); assert_eq!(error_provider, &auth_issue_provider); let providers = source.configured_providers(Catalog::builtin()).await; assert_eq!(providers, vec![configured_provider]); } }