use crate::commands::setup::shell::ShellConfig;
use crate::commands::setup::ui::{self, SetupError};
use console::style;
use dialoguer::{Input, Select};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmProvider {
Anthropic,
OpenAI,
DeepSeek,
Azure,
OpenRouter,
Custom,
Skip,
}
impl LlmProvider {
pub fn name(&self) -> &'static str {
match self {
LlmProvider::Anthropic => "Anthropic (Claude)",
LlmProvider::OpenAI => "OpenAI (GPT)",
LlmProvider::DeepSeek => "DeepSeek",
LlmProvider::Azure => "Azure OpenAI",
LlmProvider::OpenRouter => "OpenRouter",
LlmProvider::Custom => "Custom (OpenAI-compatible)",
LlmProvider::Skip => "Skip",
}
}
pub fn config_value(&self) -> &'static str {
match self {
LlmProvider::Anthropic => "anthropic",
LlmProvider::OpenAI => "openai",
LlmProvider::DeepSeek => "deepseek",
LlmProvider::Azure => "azure",
LlmProvider::OpenRouter => "openai", LlmProvider::Custom => "openai", LlmProvider::Skip => "",
}
}
pub fn default_base_url(&self) -> Option<&'static str> {
match self {
LlmProvider::Anthropic => None, LlmProvider::OpenAI => None, LlmProvider::DeepSeek => Some("https://api.deepseek.com/v1"),
LlmProvider::Azure => None, LlmProvider::OpenRouter => Some("https://openrouter.ai/api/v1"),
LlmProvider::Custom => None, LlmProvider::Skip => None,
}
}
pub fn default_models(&self) -> Vec<(&'static str, &'static str)> {
match self {
LlmProvider::Anthropic => vec![
(
"claude-sonnet-4-20250514",
"Claude Sonnet 4 (Recommended, $3/$15 per M tokens)",
),
(
"claude-haiku-4-5-20250514",
"Claude Haiku 4.5 (Fast & cheap, $1/$5 per M tokens)",
),
(
"claude-opus-4-20250514",
"Claude Opus 4 (Most capable, $15/$75 per M tokens)",
),
],
LlmProvider::OpenAI => vec![
(
"gpt-4o-mini",
"GPT-4o Mini (Recommended, $0.15/$0.6 per M tokens)",
),
("gpt-4o", "GPT-4o (More capable, $2.5/$10 per M tokens)"),
("gpt-4-turbo", "GPT-4 Turbo"),
],
LlmProvider::DeepSeek => vec![
(
"deepseek-chat",
"DeepSeek Chat (Recommended, $0.27/$1.1 per M tokens)",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner ($0.55/$2.19 per M tokens)",
),
],
LlmProvider::Azure => vec![
("gpt-4o-mini", "GPT-4o Mini deployment"),
("gpt-4o", "GPT-4o deployment"),
],
LlmProvider::OpenRouter => vec![
("anthropic/claude-sonnet-4", "Claude Sonnet 4"),
("openai/gpt-4o-mini", "GPT-4o Mini"),
("deepseek/deepseek-chat", "DeepSeek Chat"),
("google/gemini-pro-1.5", "Gemini Pro 1.5"),
],
LlmProvider::Custom => vec![],
LlmProvider::Skip => vec![],
}
}
#[allow(dead_code)]
pub fn api_key_hint(&self) -> &'static str {
match self {
LlmProvider::Anthropic => "sk-ant-... (from console.anthropic.com)",
LlmProvider::OpenAI => "sk-... (from platform.openai.com)",
LlmProvider::DeepSeek => "sk-... (from platform.deepseek.com)",
LlmProvider::Azure => "Azure API key (from Azure Portal)",
LlmProvider::OpenRouter => "sk-or-... (from openrouter.ai)",
LlmProvider::Custom => "Your API key",
LlmProvider::Skip => "",
}
}
pub fn dashboard_url(&self) -> Option<&'static str> {
match self {
LlmProvider::Anthropic => Some("https://console.anthropic.com/settings/keys"),
LlmProvider::OpenAI => Some("https://platform.openai.com/api-keys"),
LlmProvider::DeepSeek => Some("https://platform.deepseek.com/api_keys"),
LlmProvider::Azure => Some("https://portal.azure.com"),
LlmProvider::OpenRouter => Some("https://openrouter.ai/keys"),
LlmProvider::Custom => None,
LlmProvider::Skip => None,
}
}
}
#[derive(Debug, Clone)]
pub struct LlmSetupResult {
pub provider: LlmProvider,
pub api_key: String,
pub model: String,
pub base_url: Option<String>,
pub azure_api_version: Option<String>,
}
pub async fn run() -> Result<Option<LlmSetupResult>, SetupError> {
println!();
println!(" CRW supports AI-powered features for smarter scraping:");
println!();
println!(
" • {} - Generate concise summaries of pages",
style("Summary").cyan()
);
println!(
" • {} - Answer questions using search results",
style("Search Answers").cyan()
);
println!(
" • {} - Extract data into JSON schema",
style("Structured Extraction").cyan()
);
println!();
println!(" These features require an LLM API key (BYOK - Bring Your Own Key).");
println!();
let provider = prompt_provider()?;
if provider == LlmProvider::Skip {
ui::print_info("Skipping LLM setup (you can configure later)");
return Ok(None);
}
if let Some(url) = provider.dashboard_url() {
println!();
println!(" Get your API key: {}", style(url).cyan().underlined());
println!();
}
let api_key = prompt_api_key(provider)?;
let model = prompt_model(provider)?;
let base_url = prompt_base_url(provider)?;
let azure_api_version = if provider == LlmProvider::Azure {
Some(prompt_azure_version()?)
} else {
None
};
println!();
ui::print_success(&format!("LLM configured: {} / {}", provider.name(), model));
Ok(Some(LlmSetupResult {
provider,
api_key,
model,
base_url,
azure_api_version,
}))
}
fn prompt_provider() -> Result<LlmProvider, SetupError> {
let items = vec![
format!(
"{}\n • Best reasoning, great for summaries\n • Models: Claude Sonnet 4, Haiku, Opus",
style("Anthropic (Claude) - Recommended").bold()
),
format!(
"{}\n • Wide compatibility, good pricing\n • Models: GPT-4o, GPT-4o-mini",
"OpenAI (GPT)"
),
format!(
"{}\n • Best value, excellent performance\n • Models: DeepSeek Chat, Reasoner",
"DeepSeek"
),
format!(
"{}\n • Enterprise deployments\n • Requires Azure subscription",
"Azure OpenAI"
),
format!(
"{}\n • Access multiple providers with one key\n • Models from OpenAI, Anthropic, Google, etc.",
"OpenRouter"
),
format!(
"{}\n • Any OpenAI-compatible API\n • Ollama, vLLM, LocalAI, etc.",
"Custom endpoint"
),
format!(
"{}\n • Configure later or use without LLM features\n • Basic scraping still works",
style("Skip for now").dim()
),
];
let providers = [
LlmProvider::Anthropic,
LlmProvider::OpenAI,
LlmProvider::DeepSeek,
LlmProvider::Azure,
LlmProvider::OpenRouter,
LlmProvider::Custom,
LlmProvider::Skip,
];
let choice = Select::with_theme(&ui::select_style())
.with_prompt(" Which LLM provider would you like to use?")
.items(&items)
.default(0)
.interact_opt()
.map_err(ui::handle_dialoguer_error)?
.ok_or(SetupError::Cancelled)?;
Ok(providers[choice])
}
fn prompt_api_key(provider: LlmProvider) -> Result<String, SetupError> {
let api_key: String = Input::with_theme(&ui::select_style())
.with_prompt(format!(" Enter your {} API key", provider.name()))
.validate_with(|input: &String| {
if input.trim().is_empty() {
Err("API key cannot be empty")
} else if input.trim().len() < 10 {
Err("API key seems too short")
} else {
Ok(())
}
})
.interact_text()
.map_err(ui::handle_dialoguer_error)?;
Ok(api_key.trim().to_string())
}
fn prompt_model(provider: LlmProvider) -> Result<String, SetupError> {
let models = provider.default_models();
if models.is_empty() {
let model: String = Input::with_theme(&ui::select_style())
.with_prompt(" Enter the model name")
.default("gpt-4o-mini".to_string())
.interact_text()
.map_err(ui::handle_dialoguer_error)?;
return Ok(model.trim().to_string());
}
let items: Vec<String> = models.iter().map(|(_, desc)| desc.to_string()).collect();
let choice = Select::with_theme(&ui::select_style())
.with_prompt(" Which model would you like to use?")
.items(&items)
.default(0)
.interact_opt()
.map_err(ui::handle_dialoguer_error)?
.ok_or(SetupError::Cancelled)?;
Ok(models[choice].0.to_string())
}
fn prompt_base_url(provider: LlmProvider) -> Result<Option<String>, SetupError> {
if let Some(url) = provider.default_base_url() {
return Ok(Some(url.to_string()));
}
if provider != LlmProvider::Custom && provider != LlmProvider::Azure {
return Ok(None);
}
let prompt = if provider == LlmProvider::Azure {
" Enter your Azure OpenAI endpoint (e.g., https://myresource.openai.azure.com)"
} else {
" Enter the API base URL (e.g., http://localhost:11434/v1)"
};
let url: String = Input::with_theme(&ui::select_style())
.with_prompt(prompt)
.validate_with(|input: &String| {
if input.trim().is_empty() {
Err("URL cannot be empty")
} else if !input.starts_with("http://") && !input.starts_with("https://") {
Err("URL must start with http:// or https://")
} else {
Ok(())
}
})
.interact_text()
.map_err(ui::handle_dialoguer_error)?;
Ok(Some(url.trim().to_string()))
}
fn prompt_azure_version() -> Result<String, SetupError> {
let version: String = Input::with_theme(&ui::select_style())
.with_prompt(" Enter the Azure API version")
.default("2024-05-01-preview".to_string())
.interact_text()
.map_err(ui::handle_dialoguer_error)?;
Ok(version.trim().to_string())
}
pub fn add_to_shell_config(config: &mut ShellConfig, result: &LlmSetupResult) {
config.export(
"CRW_EXTRACTION__LLM__PROVIDER",
result.provider.config_value(),
);
config.export("CRW_EXTRACTION__LLM__API_KEY", &result.api_key);
config.export("CRW_EXTRACTION__LLM__MODEL", &result.model);
if let Some(ref url) = result.base_url {
config.export("CRW_EXTRACTION__LLM__BASE_URL", url);
}
if let Some(ref version) = result.azure_api_version {
config.export("CRW_EXTRACTION__LLM__AZURE_API_VERSION", version);
}
}
fn mask_api_key(key: &str) -> String {
if key.len() <= 12 {
return "*".repeat(key.len());
}
format!("{}...{}", &key[..4], &key[key.len() - 4..])
}
pub fn show_manual_config(result: &LlmSetupResult) {
println!();
println!(" Add these environment variables to your shell:");
println!();
println!(
" export CRW_EXTRACTION__LLM__PROVIDER=\"{}\"",
result.provider.config_value()
);
println!(
" export CRW_EXTRACTION__LLM__API_KEY=\"{}\"",
mask_api_key(&result.api_key)
);
println!(" export CRW_EXTRACTION__LLM__MODEL=\"{}\"", result.model);
if let Some(ref url) = result.base_url {
println!(" export CRW_EXTRACTION__LLM__BASE_URL=\"{}\"", url);
}
if let Some(ref version) = result.azure_api_version {
println!(
" export CRW_EXTRACTION__LLM__AZURE_API_VERSION=\"{}\"",
version
);
}
println!();
}