use crate::config::ProviderConfig;
pub const EXISTING_KEY_SENTINEL: &str = "__EXISTING_KEY__";
pub use crate::tui::onboarding::{PROVIDERS, ProviderInfo};
pub const CUSTOM_PROVIDER_IDX: usize = PROVIDERS.len() - 1;
pub const CUSTOM_INSTANCES_START: usize = PROVIDERS.len();
#[derive(Default)]
pub struct ProviderSelectorState {
pub selected_provider: usize,
pub custom_names: Vec<String>,
pub has_existing_key: bool,
pub api_key_input: String,
pub api_key_cursor: usize,
pub models: Vec<String>,
pub config_models: Vec<String>,
pub selected_model: usize,
pub model_filter: String,
pub models_fetching: bool,
pub zhipu_endpoint_type: usize,
pub base_url: String,
pub custom_model: String,
pub custom_name: String,
pub editing_custom_key: Option<String>,
pub context_window: String,
pub focused_field: usize,
pub showing_providers: bool,
pub codex_user_code: Option<String>,
pub codex_device_flow_status: crate::tui::onboarding::CodexDeviceFlowStatus,
}
impl ProviderSelectorState {
pub fn current_provider(&self) -> &ProviderInfo {
let idx = if self.selected_provider >= CUSTOM_PROVIDER_IDX {
CUSTOM_PROVIDER_IDX
} else {
self.selected_provider
};
&PROVIDERS[idx]
}
pub fn is_custom(&self) -> bool {
self.selected_provider >= CUSTOM_PROVIDER_IDX
}
pub fn is_cli(&self) -> bool {
let id = self.provider_id();
id == "claude-cli" || id == "opencode-cli" || id == "codex-cli"
}
pub fn is_oauth(&self) -> bool {
let id = self.provider_id();
id == "github" || id == "codex"
}
pub fn is_zhipu(&self) -> bool {
self.provider_id() == "zhipu"
}
pub fn provider_id(&self) -> &'static str {
if self.selected_provider < CUSTOM_PROVIDER_IDX {
PROVIDERS[self.selected_provider].id
} else {
"" }
}
}
pub fn index_of_provider(id: &str) -> Option<usize> {
PROVIDERS.iter().position(|p| p.id == id)
}
impl ProviderSelectorState {
pub fn supports_model_fetch(&self) -> bool {
if self.is_custom() {
return !self.base_url.trim().is_empty();
}
matches!(
self.provider_id(),
"anthropic"
| "openai"
| "github"
| "gemini"
| "openrouter"
| "zhipu"
| "opencode-cli"
| "codex-cli"
| "codex"
| "opencode"
| "ollama"
)
}
pub fn max_field(&self) -> usize {
if self.is_custom() {
6 } else if self.is_zhipu() {
4 } else {
3 }
}
pub fn has_existing_key_sentinel(&self) -> bool {
self.api_key_input == EXISTING_KEY_SENTINEL
}
pub fn provider_display_order(&self) -> Vec<usize> {
let num_customs = self.custom_names.len();
let mut static_indices: Vec<usize> = (0..CUSTOM_PROVIDER_IDX).collect();
static_indices.sort_by_key(|&i| PROVIDERS[i].name.to_ascii_lowercase());
static_indices
.into_iter()
.chain(CUSTOM_INSTANCES_START..CUSTOM_INSTANCES_START + num_customs)
.chain(std::iter::once(CUSTOM_PROVIDER_IDX))
.collect()
}
pub fn provider_has_credentials(&self, idx: usize) -> bool {
let config = match crate::config::Config::load() {
Ok(c) => c,
Err(_) => return false,
};
if idx < CUSTOM_PROVIDER_IDX {
let id = PROVIDERS[idx].id;
match id {
"claude-cli" | "opencode-cli" | "codex-cli" => {
let bin = match id {
"claude-cli" => "claude",
"opencode-cli" => "opencode",
_ => "codex",
};
which::which(bin).is_ok()
}
"github" => config
.providers
.github
.as_ref()
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty()),
"codex" => {
let token_path = crate::config::opencrabs_home()
.join("auth")
.join("codex.json");
token_path.exists()
}
"qwen" => config
.providers
.qwen
.as_ref()
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty()),
_ => crate::utils::providers::config_for(&config.providers, id)
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty()),
}
} else if idx == CUSTOM_PROVIDER_IDX {
false } else {
let custom_idx = idx - CUSTOM_INSTANCES_START;
self.custom_names
.get(custom_idx)
.and_then(|name| config.providers.custom_by_name(name))
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty())
}
}
pub fn detect_existing_key(&mut self) {
fn has_nonempty_key(p: Option<&ProviderConfig>) -> bool {
p.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty())
}
self.api_key_input.clear();
self.has_existing_key = false;
if let Ok(config) = crate::config::Config::load() {
let has_key = if self.selected_provider < CUSTOM_PROVIDER_IDX {
let id = PROVIDERS[self.selected_provider].id;
if self.is_cli() {
false } else if self.is_oauth() {
let id = PROVIDERS[self.selected_provider].id;
if id == "codex" {
let token_path = crate::config::opencrabs_home()
.join("auth")
.join("codex.json");
token_path.exists()
} else if id == "github" {
config
.providers
.github
.as_ref()
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty())
} else {
false
}
} else {
has_nonempty_key(crate::utils::providers::config_for(&config.providers, id))
}
} else if self.selected_provider == CUSTOM_PROVIDER_IDX {
self.custom_name.clear();
self.base_url.clear();
self.custom_model.clear();
self.context_window.clear();
false
} else {
let custom_idx = self.selected_provider - CUSTOM_INSTANCES_START;
if let Some(cname) = self.custom_names.get(custom_idx).cloned() {
if let Some(c) = config.providers.custom_by_name(&cname) {
self.custom_name = cname;
self.base_url = c.base_url.clone().unwrap_or_default();
self.custom_model = c.default_model.clone().unwrap_or_default();
self.context_window = c
.context_window
.map(|cw| cw.to_string())
.unwrap_or_default();
c.api_key.as_ref().is_some_and(|k| !k.is_empty())
} else {
false
}
} else {
false
}
};
self.has_existing_key = has_key;
if has_key {
self.api_key_input = EXISTING_KEY_SENTINEL.to_string();
self.api_key_cursor = 0;
}
}
self.selected_model = 0;
self.model_filter.clear();
}
pub fn load_custom_fields(&mut self) {
if self.is_zhipu()
&& let Ok(config) = crate::config::Config::load()
&& let Some(zhipu) = &config.providers.zhipu
{
self.zhipu_endpoint_type = match zhipu.endpoint_type.as_deref() {
Some("coding") => 1,
_ => 0,
};
}
if self.selected_provider == CUSTOM_PROVIDER_IDX {
self.custom_name.clear();
self.base_url.clear();
self.custom_model.clear();
self.context_window.clear();
} else if self.selected_provider >= CUSTOM_INSTANCES_START {
let custom_idx = self.selected_provider - CUSTOM_INSTANCES_START;
if let Some(cname) = self.custom_names.get(custom_idx).cloned()
&& let Ok(config) = crate::config::Config::load()
&& let Some(c) = config.providers.custom_by_name(&cname)
{
self.custom_name = cname;
self.base_url = c.base_url.clone().unwrap_or_default();
self.custom_model = c.default_model.clone().unwrap_or_default();
self.context_window = c
.context_window
.map(|cw| cw.to_string())
.unwrap_or_default();
if c.api_key.as_ref().is_some_and(|k| !k.is_empty()) {
self.api_key_input = EXISTING_KEY_SENTINEL.to_string();
}
}
}
}
pub fn load_api_key_from_config(&self) -> Option<String> {
let config = crate::config::Config::load().ok()?;
if self.selected_provider < CUSTOM_PROVIDER_IDX {
crate::utils::providers::config_for(
&config.providers,
PROVIDERS[self.selected_provider].id,
)
.and_then(|p| p.api_key.clone())
} else if self.selected_provider >= CUSTOM_INSTANCES_START {
let custom_idx = self.selected_provider - CUSTOM_INSTANCES_START;
self.custom_names.get(custom_idx).and_then(|name| {
config
.providers
.custom_by_name(name)
.and_then(|p| p.api_key.clone())
})
} else {
None
}
.filter(|k| !k.is_empty())
}
pub fn resolve_api_key(&self) -> Option<String> {
if !self.api_key_input.is_empty() && self.api_key_input != EXISTING_KEY_SENTINEL {
Some(self.api_key_input.clone())
} else {
self.load_api_key_from_config()
}
}
pub fn zhipu_endpoint_str(&self) -> Option<String> {
if self.is_zhipu() {
Some(
if self.zhipu_endpoint_type == 1 {
"coding"
} else {
"api"
}
.to_string(),
)
} else {
None
}
}
pub fn reload_config_models(&mut self) {
self.config_models.clear();
if let Ok(config) = crate::config::Config::load() {
if self.is_cli() {
return; }
if self.selected_provider < CUSTOM_PROVIDER_IDX {
let id = PROVIDERS[self.selected_provider].id;
if let Some(p) = crate::utils::providers::config_for(&config.providers, id)
&& !p.models.is_empty()
{
self.config_models = p.models.clone();
return;
}
} else if self.selected_provider >= CUSTOM_PROVIDER_IDX
&& let Some((_name, p)) = config.providers.active_custom()
&& !p.models.is_empty()
{
self.config_models = p.models.clone();
return;
}
}
self.config_models = load_default_models(self.provider_id());
}
pub fn all_model_names(&self) -> Vec<&str> {
if !self.models.is_empty() {
self.models.iter().map(|s| s.as_str()).collect()
} else if !self.config_models.is_empty() {
self.config_models.iter().map(|s| s.as_str()).collect()
} else {
self.current_provider().models.to_vec()
}
}
pub fn filtered_model_names(&self) -> Vec<&str> {
let all = self.all_model_names();
if self.model_filter.is_empty() {
all
} else {
let q = self.model_filter.to_lowercase();
all.into_iter()
.filter(|m| m.to_lowercase().contains(&q))
.collect()
}
}
pub fn model_count(&self) -> usize {
self.filtered_model_names().len()
}
pub fn selected_model_name(&self) -> &str {
let filtered = self.filtered_model_names();
if let Some(name) = filtered.get(self.selected_model) {
name
} else {
self.all_model_names().first().copied().unwrap_or("")
}
}
pub fn resolve_selected_model_index(&mut self) {
if self.custom_model.is_empty() {
return;
}
let all = self.all_model_names();
if let Some(idx) = all.iter().position(|m| *m == self.custom_model) {
self.selected_model = idx;
}
}
pub fn load_custom_names(&mut self) {
self.custom_names = crate::config::Config::load()
.ok()
.and_then(|c| c.providers.custom.map(|m| m.keys().cloned().collect()))
.unwrap_or_default();
}
}
pub fn model_display_label(model_id: &str) -> &str {
match model_id {
"qwen3.6-max-preview" => "Qwen 3.6 Max Preview",
"coder-model" | "qwen3.6-plus" | "qwen-3.6-plus" => "Qwen 3.6 Plus",
"qwen3.5-plus" | "qwen-3.5-plus" => "Qwen 3.5 Plus",
"minimax-m2.5" => "Minimax M2.5",
"minimax-m2.7" => "Minimax M2.7",
"mimo-v2-omni" | "mimo-v2-omni-free" => "Mimo V2 Omni",
"mimo-v2-pro" | "mimo-v2-pro-free" => "Mimo V2 Pro",
"kimi-k2.6" => "Kimi K2.6",
"kimi-k2.5" | "kimi-k2-5" => "Kimi K2.5",
"glm-5.1" => "GLM 5.1",
"glm-5-turbo" => "GLM 5 Turbo",
"opus-4-7" => "Opus 4.7",
"opus-4-6" => "Opus 4.6",
"sonnet-4-6" => "Sonnet 4.6",
"haiku-4-5" => "Haiku 4.5",
other => prettify_claude_cli_model(other).unwrap_or(other),
}
}
fn prettify_claude_cli_model(model: &str) -> Option<&'static str> {
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
static PRETTIFIED: LazyLock<Mutex<HashMap<String, &'static str>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
let (family, rest) = if let Some(r) = model.strip_prefix("opus-") {
("Opus", r)
} else if let Some(r) = model.strip_prefix("sonnet-") {
("Sonnet", r)
} else if let Some(r) = model.strip_prefix("haiku-") {
("Haiku", r)
} else {
return None;
};
let (major, minor) = rest.split_once('-')?;
if major.is_empty()
|| minor.is_empty()
|| !major.chars().all(|c| c.is_ascii_digit())
|| !minor.chars().all(|c| c.is_ascii_digit())
{
return None;
}
let mut cache = PRETTIFIED.lock().ok()?;
if let Some(existing) = cache.get(model) {
return Some(existing);
}
let pretty: &'static str =
Box::leak(format!("{} {}.{}", family, major, minor).into_boxed_str());
cache.insert(model.to_string(), pretty);
Some(pretty)
}
pub fn load_default_models(provider_id: &str) -> Vec<String> {
let config_content = include_str!("../../config.toml.example");
let mut models = Vec::new();
if let Ok(config) = config_content.parse::<toml::Value>()
&& let Some(providers) = config.get("providers")
{
let section_key = match provider_id {
"claude-cli" => "claude_cli",
"opencode-cli" => "opencode_cli",
"codex-cli" => "codex_cli",
"codex" => "codex", "" => "custom", other => other,
};
if section_key == "custom" {
if let Some(custom) = providers.get("custom")
&& let Some(custom_table) = custom.as_table()
{
for (_name, entry) in custom_table {
if let Some(models_arr) = entry.get("models").and_then(|m| m.as_array()) {
for model in models_arr {
if let Some(model_str) = model.as_str()
&& !models.contains(&model_str.to_string())
{
models.push(model_str.to_string());
}
}
}
}
}
} else if let Some(section) = providers.get(section_key)
&& let Some(models_arr) = section.get("models").and_then(|m| m.as_array())
{
for model in models_arr {
if let Some(model_str) = model.as_str() {
models.push(model_str.to_string());
}
}
}
}
tracing::debug!(
"Loaded {} default models from config.toml.example for provider '{}'",
models.len(),
provider_id
);
models
}