mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
fabro provider login command (#102)
This PR adds a standalone `fabro provider login --provider <name>`
command so users can re-authenticate with LLM providers outside of the
initial `fabro install` wizard—useful when tokens expire or when
switching accounts. For OpenAI, it offers the browser-based OAuth PKCE
flow with an automatic fallback to manual API key entry; all other
providers go straight to an API key prompt with validation. Credentials
are merged non-destructively into `~/.fabro/.env`.
To support this, shared authentication helpers (`provider_key_url`,
`provider_display_name`, `openai_oauth_env_pairs`, `write_env_file`,
`validate_api_key`, `prompt_and_validate_key`, and the underlying prompt
functions) are extracted from `install.rs` into a new `provider_auth.rs`
module. The install flow is updated to import from the shared module and
delegate the OpenAI OAuth-or-fallback logic to a single
`run_openai_oauth_or_api_key` helper, eliminating duplication between
the two code paths.
The new command is wired into the CLI via a `ProviderCommand::Login`
subcommand under `Command::Provider`, with clap-level parsing tests
covering valid providers, missing flags, and unknown provider names.
### Fabro Details
<details>
<summary>Ran 10 stages in 26m 57s for $7.49</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 11s | – | 0 |
| preflight_lint | 13s | – | 0 |
| implement | 9m 16s | $2.76 | 0 |
| simplify_opus | 6m 32s | $2.05 | 0 |
| simplify_gemini | 4m 44s | $1.58 | 0 |
| simplify_gpt | 4m 2s | $1.10 | 0 |
| verify | 22s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **26m 57s** | **$7.49** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (13 nodes and 16
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gemini [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
333787a417
commit
8a9b89deda
5 changed files with 383 additions and 229 deletions
|
|
@ -8,6 +8,7 @@ pub mod logs;
|
|||
pub mod parse;
|
||||
pub mod pr;
|
||||
pub mod preview;
|
||||
pub mod provider;
|
||||
pub mod rewind;
|
||||
pub mod run;
|
||||
mod run_progress;
|
||||
|
|
|
|||
37
lib/crates/fabro-cli/src/commands/provider.rs
Normal file
37
lib/crates/fabro-cli/src/commands/provider.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::provider_auth;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ProviderLoginArgs {
|
||||
/// LLM provider to authenticate with
|
||||
#[arg(long)]
|
||||
pub provider: Provider,
|
||||
}
|
||||
|
||||
pub async fn login_command(args: ProviderLoginArgs) -> Result<()> {
|
||||
let s = Styles::detect_stderr();
|
||||
let arc_dir = dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".fabro");
|
||||
std::fs::create_dir_all(&arc_dir)?;
|
||||
|
||||
let use_oauth = args.provider == Provider::OpenAi
|
||||
&& tokio::task::spawn_blocking(|| {
|
||||
provider_auth::prompt_confirm("Log in via browser (OAuth)?", true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let env_pairs = if use_oauth {
|
||||
provider_auth::run_openai_oauth_or_api_key(&s).await?
|
||||
} else {
|
||||
let (env_var, key) = provider_auth::prompt_and_validate_key(args.provider, &s).await?;
|
||||
vec![(env_var, key)]
|
||||
};
|
||||
|
||||
provider_auth::write_env_file(&arc_dir, &env_pairs, &s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use anyhow::{bail, Context, Result};
|
|||
use axum::extract::Query;
|
||||
use axum::response::Html;
|
||||
use axum::routing::get;
|
||||
use dialoguer::{Confirm, MultiSelect, Password, Select};
|
||||
use dialoguer::{MultiSelect, Select};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
use rand::Rng;
|
||||
|
|
@ -16,6 +16,10 @@ use tokio::net::TcpListener;
|
|||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::doctor;
|
||||
use crate::provider_auth::{
|
||||
prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key,
|
||||
write_env_file,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenSSL helpers (server mode only)
|
||||
|
|
@ -200,37 +204,7 @@ ca = "~/.fabro/certs/ca.crt"
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider key URLs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn provider_key_url(provider: Provider) -> &'static str {
|
||||
match provider {
|
||||
Provider::Anthropic => "https://console.anthropic.com/settings/keys",
|
||||
Provider::OpenAi => "https://platform.openai.com/api-keys",
|
||||
Provider::Gemini => "https://aistudio.google.com/apikey",
|
||||
Provider::Kimi => "https://platform.moonshot.cn/console/api-keys",
|
||||
Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys",
|
||||
Provider::Minimax => {
|
||||
"https://platform.minimaxi.com/user-center/basic-information/interface-key"
|
||||
}
|
||||
Provider::Inception => "https://console.inceptionlabs.ai/api-keys",
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI OAuth helpers
|
||||
// Binary detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if a binary exists on PATH using the doctor.rs pattern.
|
||||
|
|
@ -244,38 +218,10 @@ fn detect_binary_on_path(binary: &str) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Convert OAuth tokens to env var pairs for ~/.fabro/.env.
|
||||
fn openai_oauth_env_pairs(
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut pairs = vec![
|
||||
("OPENAI_API_KEY".to_string(), access_token.to_string()),
|
||||
(
|
||||
"OPENAI_REFRESH_TOKEN".to_string(),
|
||||
refresh_token.to_string(),
|
||||
),
|
||||
];
|
||||
if let Some(id) = account_id {
|
||||
pairs.push(("CHATGPT_ACCOUNT_ID".to_string(), id.to_string()));
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||
Ok(
|
||||
Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.default(default)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn prompt_input(prompt: &str) -> Result<String> {
|
||||
Ok(
|
||||
|
|
@ -285,14 +231,6 @@ fn prompt_input(prompt: &str) -> Result<String> {
|
|||
)
|
||||
}
|
||||
|
||||
fn prompt_password(prompt: &str) -> Result<String> {
|
||||
Ok(
|
||||
Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_select(prompt: &str, items: &[String]) -> Result<usize> {
|
||||
Ok(
|
||||
Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
|
|
@ -534,22 +472,6 @@ async fn setup_github_app(arc_dir: &Path, s: &Styles) -> Result<Vec<(String, Str
|
|||
Ok(env_pairs)
|
||||
}
|
||||
|
||||
fn write_env_file(arc_dir: &Path, env_pairs: &[(String, String)], s: &Styles) -> Result<()> {
|
||||
let env_path = arc_dir.join(".env");
|
||||
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
|
||||
let refs: Vec<(&str, &str)> = env_pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
let merged = fabro_config::dotenv::merge_env(&existing, &refs);
|
||||
fabro_config::dotenv::write_env_file(&env_path, &merged)?;
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Wrote {}", env_path.display()))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_install() -> Result<()> {
|
||||
let s = Styles::detect_stderr();
|
||||
let emoji = console::Emoji("⚒️ ", "");
|
||||
|
|
@ -637,44 +559,10 @@ pub async fn run_install() -> Result<()> {
|
|||
.await??;
|
||||
|
||||
if use_oauth {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to("Opening browser for OpenAI login...")
|
||||
);
|
||||
match fabro_openai_oauth::run_browser_flow(
|
||||
fabro_openai_oauth::DEFAULT_ISSUER,
|
||||
fabro_openai_oauth::DEFAULT_CLIENT_ID,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
tracing::info!("OpenAI OAuth browser flow completed");
|
||||
let account_id = fabro_openai_oauth::extract_account_id(&tokens);
|
||||
env_pairs.extend(openai_oauth_env_pairs(
|
||||
&tokens.access_token,
|
||||
&tokens.refresh_token,
|
||||
account_id.as_deref(),
|
||||
));
|
||||
configured_providers.push(Provider::OpenAi);
|
||||
openai_via_oauth = true;
|
||||
eprintln!(
|
||||
" {} OpenAI configured via browser login",
|
||||
s.green.apply_to("✔")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "OpenAI OAuth browser flow failed");
|
||||
eprintln!(" Browser login failed: {e}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to("Falling back to manual API key entry.")
|
||||
);
|
||||
let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, &s).await?;
|
||||
env_pairs.push((env_var, key));
|
||||
configured_providers.push(Provider::OpenAi);
|
||||
openai_via_oauth = true;
|
||||
}
|
||||
}
|
||||
let pairs = run_openai_oauth_or_api_key(&s).await?;
|
||||
env_pairs.extend(pairs);
|
||||
configured_providers.push(Provider::OpenAi);
|
||||
openai_via_oauth = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -894,67 +782,6 @@ mod hex {
|
|||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API key validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
|
||||
// Temporarily set the env var so Client::from_env() picks it up
|
||||
let env_var = provider.api_key_env_vars()[0];
|
||||
std::env::set_var(env_var, api_key);
|
||||
|
||||
let client = fabro_llm::client::Client::from_env()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let params = fabro_llm::generate::GenerateParams::new(doctor::cheapest_model(provider))
|
||||
.provider(provider.as_str())
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16)
|
||||
.client(std::sync::Arc::new(client));
|
||||
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fabro_llm::generate::generate(params),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "timeout (30s)".to_string())?
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn prompt_and_validate_key(provider: Provider, s: &Styles) -> Result<(String, String)> {
|
||||
let env_var = provider.api_key_env_vars()[0];
|
||||
let url = provider_key_url(provider);
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Get your API key at: {url}"))
|
||||
);
|
||||
|
||||
loop {
|
||||
let prompt = env_var.to_string();
|
||||
let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??;
|
||||
|
||||
eprintln!(" {}", s.dim.apply_to("Validating API key..."));
|
||||
match validate_api_key(provider, &key).await {
|
||||
Ok(()) => {
|
||||
eprintln!(" {} API key is valid", s.green.apply_to("✔"));
|
||||
return Ok((env_var.to_string(), key));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [error] API key validation failed: {e}");
|
||||
let retry = tokio::task::spawn_blocking(|| {
|
||||
prompt_confirm("Try again with a different key?", true)
|
||||
})
|
||||
.await??;
|
||||
if !retry {
|
||||
return Ok((env_var.to_string(), key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -971,33 +798,6 @@ mod tests {
|
|||
assert!(!detect_binary_on_path("arc_nonexistent_xyz"));
|
||||
}
|
||||
|
||||
// -- OpenAI OAuth env pairs --
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_api_key() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_API_KEY".to_string(), "tok".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_refresh_token() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_REFRESH_TOKEN".to_string(), "ref".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_count() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert_eq!(pairs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_with_account_id() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", Some("acct_123"));
|
||||
assert!(pairs.contains(&("CHATGPT_ACCOUNT_ID".to_string(), "acct_123".to_string())));
|
||||
assert_eq!(pairs.len(), 3);
|
||||
}
|
||||
|
||||
// -- Session secret (server only) --
|
||||
|
||||
#[test]
|
||||
|
|
@ -1151,23 +951,4 @@ mod tests {
|
|||
assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key"));
|
||||
assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt"));
|
||||
}
|
||||
|
||||
// -- Provider key URLs --
|
||||
|
||||
#[test]
|
||||
fn every_provider_has_key_url() {
|
||||
for provider in Provider::ALL {
|
||||
let url = provider_key_url(*provider);
|
||||
assert!(!url.is_empty(), "{provider:?} has empty URL");
|
||||
assert!(url.starts_with("https://"), "{provider:?} URL: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
// -- API key validation --
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_api_key_rejects_invalid_key() {
|
||||
let result = validate_api_key(Provider::Anthropic, "sk-invalid-key-12345").await;
|
||||
assert!(result.is_err(), "expected invalid key to be rejected");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod doctor;
|
|||
mod init;
|
||||
mod install;
|
||||
mod logging;
|
||||
mod provider_auth;
|
||||
mod skill;
|
||||
mod upgrade;
|
||||
|
||||
|
|
@ -160,6 +161,11 @@ enum Command {
|
|||
#[command(subcommand)]
|
||||
command: RepoCommand,
|
||||
},
|
||||
/// Provider operations
|
||||
Provider {
|
||||
#[command(subcommand)]
|
||||
command: ProviderCommand,
|
||||
},
|
||||
/// System maintenance commands
|
||||
System {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -240,6 +246,12 @@ enum WorkflowCommand {
|
|||
Create(commands::workflow::WorkflowCreateArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProviderCommand {
|
||||
/// Log in to an LLM provider
|
||||
Login(commands::provider::ProviderLoginArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AssetCommand {
|
||||
/// List assets for a workflow run
|
||||
|
|
@ -506,6 +518,9 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
Command::Discord => "discord",
|
||||
Command::Docs => "docs",
|
||||
Command::Upgrade(_) => "upgrade",
|
||||
Command::Provider { command } => match command {
|
||||
ProviderCommand::Login(_) => "provider login",
|
||||
},
|
||||
Command::System { command } => match command {
|
||||
SystemCommand::Prune(_) => "system prune",
|
||||
SystemCommand::Df(_) => "system df",
|
||||
|
|
@ -899,6 +914,11 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
Command::Upgrade(args) => {
|
||||
upgrade::run_upgrade(args).await?;
|
||||
}
|
||||
Command::Provider { command } => match command {
|
||||
ProviderCommand::Login(args) => {
|
||||
commands::provider::login_command(args).await?;
|
||||
}
|
||||
},
|
||||
Command::System { command } => match command {
|
||||
SystemCommand::Prune(args) => {
|
||||
commands::runs::prune_command(&args)?;
|
||||
|
|
@ -930,3 +950,49 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
|
||||
(command_name, result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_openai() {
|
||||
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"])
|
||||
.expect("should parse");
|
||||
match cli.command {
|
||||
Command::Provider {
|
||||
command: ProviderCommand::Login(args),
|
||||
} => {
|
||||
assert_eq!(args.provider, fabro_llm::provider::Provider::OpenAi);
|
||||
}
|
||||
_ => panic!("unexpected command variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_anthropic() {
|
||||
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "anthropic"])
|
||||
.expect("should parse");
|
||||
match cli.command {
|
||||
Command::Provider {
|
||||
command: ProviderCommand::Login(args),
|
||||
} => {
|
||||
assert_eq!(args.provider, fabro_llm::provider::Provider::Anthropic);
|
||||
}
|
||||
_ => panic!("unexpected command variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_missing_provider_flag() {
|
||||
let result = Cli::try_parse_from(["fabro", "provider", "login"]);
|
||||
assert!(result.is_err(), "should fail without --provider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_bogus_provider() {
|
||||
let result = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "bogus"]);
|
||||
assert!(result.is_err(), "should fail with unknown provider");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
lib/crates/fabro-cli/src/provider_auth.rs
Normal file
269
lib/crates/fabro-cli/src/provider_auth.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use dialoguer::{Confirm, Password};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::doctor;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider key URLs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn provider_key_url(provider: Provider) -> &'static str {
|
||||
match provider {
|
||||
Provider::Anthropic => "https://console.anthropic.com/settings/keys",
|
||||
Provider::OpenAi => "https://platform.openai.com/api-keys",
|
||||
Provider::Gemini => "https://aistudio.google.com/apikey",
|
||||
Provider::Kimi => "https://platform.moonshot.cn/console/api-keys",
|
||||
Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys",
|
||||
Provider::Minimax => {
|
||||
"https://platform.minimaxi.com/user-center/basic-information/interface-key"
|
||||
}
|
||||
Provider::Inception => "https://console.inceptionlabs.ai/api-keys",
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI OAuth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert OAuth tokens to env var pairs for ~/.fabro/.env.
|
||||
pub(crate) fn openai_oauth_env_pairs(
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut pairs = vec![
|
||||
("OPENAI_API_KEY".to_string(), access_token.to_string()),
|
||||
(
|
||||
"OPENAI_REFRESH_TOKEN".to_string(),
|
||||
refresh_token.to_string(),
|
||||
),
|
||||
];
|
||||
if let Some(id) = account_id {
|
||||
pairs.push(("CHATGPT_ACCOUNT_ID".to_string(), id.to_string()));
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI OAuth browser flow with API-key fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the OpenAI OAuth browser flow, falling back to manual API key entry on
|
||||
/// failure. Returns the env-var pairs to persist.
|
||||
pub(crate) async fn run_openai_oauth_or_api_key(s: &Styles) -> Result<Vec<(String, String)>> {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to("Opening browser for OpenAI login...")
|
||||
);
|
||||
match fabro_openai_oauth::run_browser_flow(
|
||||
fabro_openai_oauth::DEFAULT_ISSUER,
|
||||
fabro_openai_oauth::DEFAULT_CLIENT_ID,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
tracing::info!("OpenAI OAuth browser flow completed");
|
||||
let account_id = fabro_openai_oauth::extract_account_id(&tokens);
|
||||
let pairs = openai_oauth_env_pairs(
|
||||
&tokens.access_token,
|
||||
&tokens.refresh_token,
|
||||
account_id.as_deref(),
|
||||
);
|
||||
eprintln!(
|
||||
" {} OpenAI configured via browser login",
|
||||
s.green.apply_to("✔")
|
||||
);
|
||||
Ok(pairs)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "OpenAI OAuth browser flow failed");
|
||||
eprintln!(" Browser login failed: {e}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to("Falling back to manual API key entry.")
|
||||
);
|
||||
let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, s).await?;
|
||||
Ok(vec![(env_var, key)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||
Ok(
|
||||
Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.default(default)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_password(prompt: &str) -> Result<String> {
|
||||
Ok(
|
||||
Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env file writing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn write_env_file(
|
||||
arc_dir: &Path,
|
||||
env_pairs: &[(String, String)],
|
||||
s: &Styles,
|
||||
) -> Result<()> {
|
||||
let env_path = arc_dir.join(".env");
|
||||
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
|
||||
let refs: Vec<(&str, &str)> = env_pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
let merged = fabro_config::dotenv::merge_env(&existing, &refs);
|
||||
fabro_config::dotenv::write_env_file(&env_path, &merged)?;
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Wrote {}", env_path.display()))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API key validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
|
||||
// Temporarily set the env var so Client::from_env() picks it up
|
||||
let env_var = provider.api_key_env_vars()[0];
|
||||
std::env::set_var(env_var, api_key);
|
||||
|
||||
let client = fabro_llm::client::Client::from_env()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let params = fabro_llm::generate::GenerateParams::new(doctor::cheapest_model(provider))
|
||||
.provider(provider.as_str())
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16)
|
||||
.client(std::sync::Arc::new(client));
|
||||
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fabro_llm::generate::generate(params),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "timeout (30s)".to_string())?
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn prompt_and_validate_key(
|
||||
provider: Provider,
|
||||
s: &Styles,
|
||||
) -> Result<(String, String)> {
|
||||
let env_var = provider.api_key_env_vars()[0];
|
||||
let url = provider_key_url(provider);
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Get your API key at: {url}"))
|
||||
);
|
||||
|
||||
loop {
|
||||
let prompt = env_var.to_string();
|
||||
let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??;
|
||||
|
||||
eprintln!(" {}", s.dim.apply_to("Validating API key..."));
|
||||
match validate_api_key(provider, &key).await {
|
||||
Ok(()) => {
|
||||
eprintln!(" {} API key is valid", s.green.apply_to("✔"));
|
||||
return Ok((env_var.to_string(), key));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [error] API key validation failed: {e}");
|
||||
let retry = tokio::task::spawn_blocking(|| {
|
||||
prompt_confirm("Try again with a different key?", true)
|
||||
})
|
||||
.await??;
|
||||
if !retry {
|
||||
return Ok((env_var.to_string(), key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- OpenAI OAuth env pairs --
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_api_key() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_API_KEY".to_string(), "tok".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_refresh_token() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_REFRESH_TOKEN".to_string(), "ref".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_count() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert_eq!(pairs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_with_account_id() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", Some("acct_123"));
|
||||
assert!(pairs.contains(&("CHATGPT_ACCOUNT_ID".to_string(), "acct_123".to_string())));
|
||||
assert_eq!(pairs.len(), 3);
|
||||
}
|
||||
|
||||
// -- Provider key URLs --
|
||||
|
||||
#[test]
|
||||
fn every_provider_has_key_url() {
|
||||
for provider in Provider::ALL {
|
||||
let url = provider_key_url(*provider);
|
||||
assert!(!url.is_empty(), "{provider:?} has empty URL");
|
||||
assert!(url.starts_with("https://"), "{provider:?} URL: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
// -- API key validation --
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_api_key_rejects_invalid_key() {
|
||||
let result = validate_api_key(Provider::Anthropic, "sk-invalid-key-12345").await;
|
||||
assert!(result.is_err(), "expected invalid key to be rejected");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue