Clean up provider auth helper duplication

Share provider display names and OAuth expiry helpers across auth, CLI,
and server code, and simplify the small match arms and helper plumbing
that full-workspace clippy surfaced during final verification.
This commit is contained in:
Bryan Helmkamp 2026-04-13 07:02:03 -04:00
parent a1d8b738d8
commit 43d73cb4a1
9 changed files with 52 additions and 80 deletions

View file

@ -42,6 +42,11 @@ pub struct OAuthTokens {
pub expires_at: DateTime<Utc>,
}
pub(crate) fn expires_at_from_now(expires_in: Option<u64>) -> DateTime<Utc> {
let seconds = i64::try_from(expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
Utc::now() + Duration::seconds(seconds)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OAuthConfig {
pub auth_url: String,

View file

@ -1,11 +1,4 @@
use chrono::{Duration, Utc};
use crate::credential::{AuthCredential, AuthDetails, OAuthTokens};
fn expires_at_from_now(expires_in: Option<u64>) -> chrono::DateTime<Utc> {
let seconds = i64::try_from(expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
Utc::now() + Duration::seconds(seconds)
}
use crate::credential::{AuthCredential, AuthDetails, OAuthTokens, expires_at_from_now};
pub async fn refresh_oauth_credential(
credential: &AuthCredential,

View file

@ -5,6 +5,7 @@ use fabro_model::Provider;
use fabro_vault::Vault;
use shlex::try_quote;
use tokio::sync::RwLock as AsyncRwLock;
use tokio::task::spawn_blocking;
use crate::credential::{ApiKeyHeader, AuthCredential, AuthDetails, credential_id_for};
use crate::refresh::refresh_oauth_credential;
@ -107,7 +108,7 @@ impl CredentialResolver {
})?;
let refreshed_for_store = refreshed.clone();
let vault = Arc::clone(&self.vault);
tokio::task::spawn_blocking(move || {
spawn_blocking(move || {
let mut vault = vault.blocking_write();
vault_set_credential(&mut vault, &credential_id, &refreshed_for_store)
.map(|_| ())
@ -130,7 +131,7 @@ impl CredentialResolver {
self.to_api_credential(&vault, &credential),
)),
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
self.to_cli_credential(&credential, kind),
Self::to_cli_credential(&credential, kind),
)),
}
}
@ -147,7 +148,7 @@ impl CredentialResolver {
}
}
for env_var in env_vars_for(provider) {
for env_var in provider.api_key_env_vars() {
if let Some(value) = self.lookup_env_or_vault(vault, env_var) {
return Ok(AuthCredential {
provider,
@ -169,10 +170,13 @@ impl CredentialResolver {
Provider::Anthropic => self.lookup_env_or_vault(vault, "ANTHROPIC_BASE_URL"),
Provider::OpenAi => self.lookup_env_or_vault(vault, "OPENAI_BASE_URL"),
Provider::Gemini => self.lookup_env_or_vault(vault, "GEMINI_BASE_URL"),
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None,
Provider::OpenAiCompatible => None,
Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception
| Provider::OpenAiCompatible => None,
};
let mut api_credential = match &credential.details {
match &credential.details {
AuthDetails::ApiKey { key } => ApiCredential {
provider: credential.provider,
auth_header: match credential.provider {
@ -213,14 +217,10 @@ impl CredentialResolver {
project_id: self.lookup_env_or_vault(vault, "OPENAI_PROJECT_ID"),
}
}
};
if api_credential.provider == Provider::OpenAi && api_credential.codex_mode {
api_credential.base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
}
api_credential
}
fn to_cli_credential(&self, credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
fn to_cli_credential(credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
let mut env_vars = HashMap::new();
let login_command = match (&credential.provider, &credential.details, kind) {
(Provider::OpenAi, AuthDetails::ApiKey { key }, CliAgentKind::Codex) => {
@ -241,7 +241,7 @@ impl CredentialResolver {
Some(codex_login_command(&tokens.access_token))
}
(_, AuthDetails::ApiKey { key }, _) => {
if let Some(name) = env_vars_for(credential.provider).first() {
if let Some(name) = credential.provider.api_key_env_vars().first() {
env_vars.insert((*name).to_string(), key.clone());
}
None
@ -260,22 +260,17 @@ impl CredentialResolver {
}
fn codex_login_command(api_key: &str) -> String {
let quoted = try_quote(api_key)
.map(std::borrow::Cow::into_owned)
.unwrap_or_else(|_| api_key.to_string());
let quoted =
try_quote(api_key).map_or_else(|_| api_key.to_string(), std::borrow::Cow::into_owned);
format!("PATH=\"$HOME/.local/bin:$PATH\" echo {quoted} | codex login --with-api-key")
}
fn env_vars_for(provider: Provider) -> &'static [&'static str] {
provider.api_key_env_vars()
}
fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&'static str] {
match (provider, usage) {
(Provider::OpenAi, CredentialUsage::ApiRequest) => &["openai"],
(Provider::OpenAi, CredentialUsage::CliAgent(CliAgentKind::Codex)) => {
&["openai_codex", "openai"]
}
(Provider::OpenAi, _) => &["openai"],
(Provider::Anthropic, _) => &["anthropic"],
(Provider::Gemini, _) => &["gemini"],
(Provider::Kimi, _) => &["kimi"],
@ -283,7 +278,6 @@ fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&
(Provider::Minimax, _) => &["minimax"],
(Provider::Inception, _) => &["inception"],
(Provider::OpenAiCompatible, _) => &[],
(Provider::OpenAi, _) => &["openai"],
}
}

View file

@ -9,7 +9,9 @@ use serde_json::json;
use tokio::time::sleep;
use crate::context::{AuthContextRequest, AuthContextResponse};
use crate::credential::{AuthCredential, AuthDetails, OAuthConfig, OAuthTokens};
use crate::credential::{
AuthCredential, AuthDetails, OAuthConfig, OAuthTokens, expires_at_from_now,
};
use crate::strategy::AuthStrategy;
const DEVICE_AUTH_TIMEOUT: Duration = Duration::from_secs(15 * 60);
@ -30,11 +32,6 @@ fn join_url(base: &str, path: &str) -> String {
format!("{}{}", base.trim_end_matches('/'), path)
}
fn expires_at_from_now(expires_in: Option<u64>) -> chrono::DateTime<chrono::Utc> {
let seconds = i64::try_from(expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
chrono::Utc::now() + chrono::Duration::seconds(seconds)
}
#[derive(Debug, Deserialize)]
struct JwtPayload {
#[serde(default)]
@ -155,7 +152,7 @@ impl CodexDeviceStrategy {
}
match payload.status.as_deref() {
Some("pending") | Some("running") | None => {
Some("pending" | "running") | None => {
sleep(DEVICE_AUTH_POLL_INTERVAL).await;
}
Some(other) => {

View file

@ -711,7 +711,7 @@ async fn persist_vault_secrets(
.body(CreateSecretRequest {
name: secret.name.clone(),
value: secret.value.clone(),
type_: secret.type_.clone(),
type_: secret.type_,
description: secret.description.clone(),
})
.send()
@ -726,14 +726,14 @@ async fn persist_vault_secrets(
store.set(
&secret.name,
&secret.value,
local_secret_type(&secret.type_),
local_secret_type(secret.type_),
secret.description.as_deref(),
)?;
}
Ok(())
}
fn local_secret_type(secret_type: &ApiSecretType) -> SecretType {
fn local_secret_type(secret_type: ApiSecretType) -> SecretType {
match secret_type {
ApiSecretType::Environment => SecretType::Environment,
ApiSecretType::File => SecretType::File,

View file

@ -36,16 +36,7 @@ pub(crate) fn provider_key_url(provider: Provider) -> &'static str {
}
pub(crate) fn provider_display_name(provider: Provider) -> &'static str {
match provider {
Provider::Anthropic => "Anthropic",
Provider::OpenAi => "OpenAI",
Provider::Gemini => "Gemini",
Provider::Kimi => "Kimi",
Provider::Zai => "Zai",
Provider::Minimax => "Minimax",
Provider::Inception => "Inception",
Provider::OpenAiCompatible => "OpenAI Compatible",
}
provider.display_name()
}
// ---------------------------------------------------------------------------
@ -176,25 +167,18 @@ pub(crate) async fn authenticate_provider_with_method(
) -> Result<AuthCredential> {
let mut strategy = strategy_for(provider, method);
let request = strategy.init().await?;
present_to_user(&request, s, printer)?;
present_to_user(&request, s, printer);
let response = await_user_response(&request, s, printer).await?;
strategy.complete(response).await
}
pub(crate) fn present_to_user(
request: &AuthContextRequest,
s: &Styles,
printer: Printer,
) -> Result<()> {
pub(crate) fn present_to_user(request: &AuthContextRequest, s: &Styles, printer: Printer) {
match request {
AuthContextRequest::ApiKey {
provider,
env_var_names,
} => {
let env_var = env_var_names
.first()
.map(String::as_str)
.unwrap_or("API_KEY");
let env_var = env_var_names.first().map_or("API_KEY", String::as_str);
let url = provider_key_url(*provider);
fabro_util::printerr!(
printer,
@ -224,7 +208,6 @@ pub(crate) fn present_to_user(
);
}
}
Ok(())
}
pub(crate) async fn await_user_response(

View file

@ -412,8 +412,7 @@ impl Client {
fn auth_value(auth_header: &ApiKeyHeader) -> String {
match auth_header {
ApiKeyHeader::Bearer(value) => value.clone(),
ApiKeyHeader::Custom { value, .. } => value.clone(),
ApiKeyHeader::Bearer(value) | ApiKeyHeader::Custom { value, .. } => value.clone(),
}
}

View file

@ -81,6 +81,21 @@ impl Provider {
.unwrap_or(Self::Anthropic)
}
/// Human-readable display name for the provider.
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::Anthropic => "Anthropic",
Self::OpenAi => "OpenAI",
Self::Gemini => "Gemini",
Self::Kimi => "Kimi",
Self::Zai => "Zai",
Self::Minimax => "Minimax",
Self::Inception => "Inception",
Self::OpenAiCompatible => "OpenAI Compatible",
}
}
/// Stable lowercase string representation used in `Request.provider`,
/// adapter names, and other serialization boundaries.
#[must_use]

View file

@ -109,8 +109,7 @@ impl ProviderCredentials {
.await
{
Ok(ResolvedCredential::Api(credential)) => api_credentials.push(credential),
Ok(ResolvedCredential::Cli(_)) => {}
Err(ResolveError::NotConfigured(_)) => {}
Ok(ResolvedCredential::Cli(_)) | Err(ResolveError::NotConfigured(_)) => {}
Err(err) => auth_issues.push((*provider, err)),
}
}
@ -131,32 +130,19 @@ pub(crate) struct LlmClientResult {
pub auth_issues: Vec<(Provider, ResolveError)>,
}
pub(crate) fn provider_display_name(provider: Provider) -> &'static str {
match provider {
Provider::Anthropic => "Anthropic",
Provider::OpenAi => "OpenAI",
Provider::Gemini => "Gemini",
Provider::Kimi => "Kimi",
Provider::Zai => "Zai",
Provider::Minimax => "Minimax",
Provider::Inception => "Inception",
Provider::OpenAiCompatible => "OpenAI Compatible",
}
}
pub(crate) fn auth_issue_message(provider: Provider, err: &ResolveError) -> String {
match err {
ResolveError::NotConfigured(_) => {
format!("{} is not configured", provider_display_name(provider))
format!("{} is not configured", provider.display_name())
}
ResolveError::RefreshFailed { source, .. } => format!(
"{} requires re-authentication: {}",
provider_display_name(provider),
provider.display_name(),
source
),
ResolveError::RefreshTokenMissing(_) => format!(
"{} requires re-authentication: refresh token missing",
provider_display_name(provider)
provider.display_name()
),
}
}