refactor(auth): extract ApiCredential::from_api_key

The "Anthropic uses x-api-key header, everyone else uses Bearer" logic
was written three times: env_source (env-based construction), resolve
(vault-based construction), and provider_auth (CLI key validation).
Any future header rename would need three edits.

Add ApiCredential::from_api_key(provider, key) as a canonical
constructor. Each callsite now builds via the helper and overrides only
the fields specific to its path (env base URLs, vault-sourced org/project
IDs, codex mode, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-24 09:05:54 -04:00
parent 7e83cd38e5
commit b4bc9a0506
No known key found for this signature in database
3 changed files with 61 additions and 97 deletions

View file

@ -1,11 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_model::Provider;
use crate::credential_source::{CredentialSource, ResolvedCredentials};
use crate::{ApiCredential, ApiKeyHeader, EnvLookup};
use crate::{ApiCredential, EnvLookup};
#[derive(Clone)]
pub struct EnvCredentialSource {
@ -33,66 +32,32 @@ impl EnvCredentialSource {
.iter()
.find_map(|var| self.lookup(var))?;
Some(match provider {
Provider::Anthropic => ApiCredential {
provider,
auth_header: ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: key,
},
extra_headers: HashMap::new(),
base_url: self.lookup("ANTHROPIC_BASE_URL"),
codex_mode: false,
org_id: None,
project_id: None,
},
let mut cred = ApiCredential::from_api_key(provider, key);
match provider {
Provider::Anthropic => {
cred.base_url = self.lookup("ANTHROPIC_BASE_URL");
}
Provider::OpenAi => {
let mut extra_headers = HashMap::new();
let mut base_url = self.lookup("OPENAI_BASE_URL");
let mut codex_mode = false;
cred.base_url = self.lookup("OPENAI_BASE_URL");
cred.org_id = self.lookup("OPENAI_ORG_ID");
cred.project_id = self.lookup("OPENAI_PROJECT_ID");
if let Some(account_id) = self.lookup("CHATGPT_ACCOUNT_ID") {
base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
codex_mode = true;
extra_headers.insert("ChatGPT-Account-Id".to_string(), account_id);
extra_headers.insert("originator".to_string(), "fabro".to_string());
}
ApiCredential {
provider,
auth_header: ApiKeyHeader::Bearer(key),
extra_headers,
base_url,
codex_mode,
org_id: self.lookup("OPENAI_ORG_ID"),
project_id: self.lookup("OPENAI_PROJECT_ID"),
cred.base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
cred.codex_mode = true;
cred.extra_headers
.insert("ChatGPT-Account-Id".to_string(), account_id);
cred.extra_headers
.insert("originator".to_string(), "fabro".to_string());
}
}
Provider::Gemini => ApiCredential {
provider,
auth_header: ApiKeyHeader::Bearer(key),
extra_headers: HashMap::new(),
base_url: self.lookup("GEMINI_BASE_URL"),
codex_mode: false,
org_id: None,
project_id: None,
},
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => {
bearer_credential(provider, key)
Provider::Gemini => {
cred.base_url = self.lookup("GEMINI_BASE_URL");
}
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => {}
// OpenAiCompatible has no api_key_env_vars, so find_map returned None above.
Provider::OpenAiCompatible => unreachable!(),
})
}
}
fn bearer_credential(provider: Provider, key: String) -> ApiCredential {
ApiCredential {
provider,
auth_header: ApiKeyHeader::Bearer(key),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}
Some(cred)
}
}

View file

@ -37,6 +37,32 @@ pub struct ApiCredential {
pub project_id: Option<String>,
}
impl ApiCredential {
/// Build an `ApiCredential` from just an API key. Picks the right
/// auth header kind for the provider (Anthropic uses `x-api-key`;
/// everyone else uses `Authorization: Bearer`). All other fields
/// default to empty.
#[must_use]
pub fn from_api_key(provider: Provider, key: String) -> Self {
let auth_header = match provider {
Provider::Anthropic => ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: key,
},
_ => ApiKeyHeader::Bearer(key),
};
Self {
provider,
auth_header,
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliCredential {
pub env_vars: HashMap<String, String>,
@ -202,7 +228,6 @@ impl CredentialResolver {
}
fn to_api_credential(&self, vault: &Vault, credential: &AuthCredential) -> ApiCredential {
let mut extra_headers = HashMap::new();
let base_url = match credential.provider {
Provider::Anthropic => self.lookup_env_or_vault(vault, "ANTHROPIC_BASE_URL"),
Provider::OpenAi => self.lookup_env_or_vault(vault, "OPENAI_BASE_URL"),
@ -213,32 +238,19 @@ impl CredentialResolver {
}
};
match &credential.details {
AuthDetails::ApiKey { key } => ApiCredential {
provider: credential.provider,
auth_header: match credential.provider {
Provider::Anthropic => ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: key.clone(),
},
_ => ApiKeyHeader::Bearer(key.clone()),
},
extra_headers,
base_url,
codex_mode: false,
org_id: if credential.provider == Provider::OpenAi {
self.lookup_env_or_vault(vault, "OPENAI_ORG_ID")
} else {
None
},
project_id: if credential.provider == Provider::OpenAi {
self.lookup_env_or_vault(vault, "OPENAI_PROJECT_ID")
} else {
None
},
},
AuthDetails::ApiKey { key } => {
let mut cred = ApiCredential::from_api_key(credential.provider, key.clone());
cred.base_url = base_url;
if credential.provider == Provider::OpenAi {
cred.org_id = self.lookup_env_or_vault(vault, "OPENAI_ORG_ID");
cred.project_id = self.lookup_env_or_vault(vault, "OPENAI_PROJECT_ID");
}
cred
}
AuthDetails::CodexOAuth {
tokens, account_id, ..
} => {
let mut extra_headers = HashMap::new();
if let Some(account_id) = account_id {
extra_headers.insert("ChatGPT-Account-Id".to_string(), account_id.clone());
extra_headers.insert("originator".to_string(), "fabro".to_string());

View file

@ -15,8 +15,8 @@ use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_auth::{
ApiCredential, ApiKeyHeader, AuthContextRequest, AuthContextResponse, AuthCredential,
AuthMethod, codex_oauth_config, strategy_for,
ApiCredential, AuthContextRequest, AuthContextResponse, AuthCredential, AuthMethod,
codex_oauth_config, strategy_for,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
@ -78,23 +78,10 @@ pub(crate) enum ApiKeySource {
// ---------------------------------------------------------------------------
pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
let auth_header = if provider == Provider::Anthropic {
ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: api_key.to_string(),
}
} else {
ApiKeyHeader::Bearer(api_key.to_string())
};
let client = LlmClient::from_credentials(vec![ApiCredential {
let client = LlmClient::from_credentials(vec![ApiCredential::from_api_key(
provider,
auth_header,
extra_headers: std::collections::HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}])
api_key.to_string(),
)])
.await
.map_err(|e| e.to_string())?;