diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 7f8963d9f..c7c4030a5 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -593,6 +593,8 @@ pub struct ProviderConfig { pub default_model: Option, /// Default provider to use (claude|openai|copilot|openrouter) pub default_provider: Option, + /// Providers excluded from auto-selection, fallback, and model routing. + pub disabled_providers: Vec, /// Reasoning effort for OpenAI Responses API (none|low|medium|high|xhigh) pub openai_reasoning_effort: Option, /// OpenAI transport mode (auto|websocket|https) @@ -618,6 +620,7 @@ impl Default for ProviderConfig { Self { default_model: None, default_provider: None, + disabled_providers: Vec::new(), openai_reasoning_effort: Some("low".to_string()), openai_transport: None, openai_service_tier: Some("priority".to_string()), diff --git a/src/auth/copilot.rs b/src/auth/copilot.rs index 4bdc497e5..b44b77439 100644 --- a/src/auth/copilot.rs +++ b/src/auth/copilot.rs @@ -135,24 +135,27 @@ impl CopilotApiToken { } } +const COPILOT_ENV_KEYS: &[&str] = &["COPILOT_GITHUB_TOKEN"]; +const COPILOT_GENERIC_GITHUB_ENV_KEYS: &[&str] = + &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; + /// Load a GitHub OAuth token from standard Copilot/CLI config locations. /// /// Checks in order: /// 1. COPILOT_GITHUB_TOKEN environment variable -/// 2. GH_TOKEN environment variable -/// 3. GITHUB_TOKEN environment variable -/// 4. ~/.copilot/config.json (official Copilot CLI plaintext fallback) -/// 5. ~/.config/github-copilot/hosts.json (legacy Copilot CLI) -/// 6. ~/.config/github-copilot/apps.json (legacy VS Code) -/// 7. trusted OpenCode/pi auth.json OAuth entries -/// 8. optional `gh auth token` fallback when JCODE_COPILOT_ALLOW_GH_AUTH_TOKEN=1 +/// 2. optionally GH_TOKEN/GITHUB_TOKEN when JCODE_COPILOT_ALLOW_GITHUB_ENV=1 +/// 3. ~/.copilot/config.json (official Copilot CLI plaintext fallback) +/// 4. ~/.config/github-copilot/hosts.json (legacy Copilot CLI) +/// 5. ~/.config/github-copilot/apps.json (legacy VS Code) +/// 6. trusted OpenCode/pi auth.json OAuth entries +/// 7. optional `gh auth token` fallback when JCODE_COPILOT_ALLOW_GH_AUTH_TOKEN=1 pub fn load_github_token() -> Result { if let Some(token) = cached_github_token() { return Ok(token); } - for env_key in ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] { - if let Ok(token) = std::env::var(env_key) + for env_key in copilot_env_keys() { + if let Ok(token) = std::env::var(*env_key) && !token.trim().is_empty() { let token = token.trim().to_string(); @@ -205,13 +208,22 @@ pub fn load_github_token() -> Result { anyhow::bail!( "GitHub Copilot token not found. \ - Set COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN, run `jcode login --provider copilot`, \ + Set COPILOT_GITHUB_TOKEN, run `jcode login --provider copilot`, \ + set JCODE_COPILOT_ALLOW_GITHUB_ENV=1 to explicitly reuse GH_TOKEN/GITHUB_TOKEN, \ or set JCODE_COPILOT_ALLOW_GH_AUTH_TOKEN=1 to explicitly reuse `gh auth token`." ) } +fn allow_generic_github_env_for_copilot() -> bool { + truthy_env("JCODE_COPILOT_ALLOW_GITHUB_ENV") +} + fn allow_gh_cli_fallback() -> bool { - std::env::var("JCODE_COPILOT_ALLOW_GH_AUTH_TOKEN") + truthy_env("JCODE_COPILOT_ALLOW_GH_AUTH_TOKEN") +} + +fn truthy_env(key: &str) -> bool { + std::env::var(key) .ok() .map(|value| { let value = value.trim(); @@ -220,15 +232,21 @@ fn allow_gh_cli_fallback() -> bool { .unwrap_or(false) } +fn copilot_env_keys() -> &'static [&'static str] { + if allow_generic_github_env_for_copilot() { + COPILOT_GENERIC_GITHUB_ENV_KEYS + } else { + COPILOT_ENV_KEYS + } +} + fn copilot_env_token_present() -> bool { - ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] - .into_iter() - .any(|env_key| { - std::env::var(env_key) - .ok() - .map(|token| !token.trim().is_empty()) - .unwrap_or(false) - }) + copilot_env_keys().iter().any(|env_key| { + std::env::var(*env_key) + .ok() + .map(|token| !token.trim().is_empty()) + .unwrap_or(false) + }) } /// Return true when a recent `auth-test` proved the discovered Copilot token is @@ -281,8 +299,8 @@ pub fn has_copilot_credentials() -> bool { pub fn has_copilot_credentials_fast() -> bool { use crate::auth::external::{ExternalAuthSource, source_has_copilot_oauth}; - for env_key in ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] { - if let Ok(token) = std::env::var(env_key) + for env_key in copilot_env_keys() { + if let Ok(token) = std::env::var(*env_key) && !token.trim().is_empty() { cache_github_token(token.trim()); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index e0d4f412e..927f1afa7 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -160,6 +160,10 @@ fn model_smoke_readiness_for_provider(provider: LoginProviderDescriptor) -> Auth } fn copilot_auth_state_from_credentials() -> (AuthState, bool) { + if provider_key_disabled("copilot") { + return (AuthState::NotConfigured, false); + } + if !copilot::has_copilot_credentials_fast() { return (AuthState::NotConfigured, false); } @@ -171,6 +175,17 @@ fn copilot_auth_state_from_credentials() -> (AuthState, bool) { } } +fn provider_key_disabled(key: &str) -> bool { + crate::config::config() + .disabled_provider_entries() + .any(|raw| { + let value = raw.trim(); + value.eq_ignore_ascii_case(key) + || (key.eq_ignore_ascii_case("copilot") + && value.eq_ignore_ascii_case("github copilot")) + }) +} + impl AuthStatus { /// Check all authentication sources and return their status. /// Results are cached for 30 seconds to avoid expensive PATH scanning on every frame. @@ -262,7 +277,13 @@ impl AuthStatus { LoginProviderAuthStateKey::Azure => self.azure, LoginProviderAuthStateKey::Bedrock => self.bedrock, LoginProviderAuthStateKey::OpenRouterLike => self.openrouter, - LoginProviderAuthStateKey::Copilot => self.copilot, + LoginProviderAuthStateKey::Copilot => { + if provider_key_disabled("copilot") { + AuthState::NotConfigured + } else { + self.copilot + } + } LoginProviderAuthStateKey::Antigravity => self.antigravity, LoginProviderAuthStateKey::Gemini => self.gemini, LoginProviderAuthStateKey::Cursor => self.cursor, @@ -321,6 +342,13 @@ impl AuthStatus { AuthState::NotConfigured } } + crate::provider_catalog::LoginProviderTarget::Copilot => { + if provider_key_disabled("copilot") { + AuthState::NotConfigured + } else { + self.state_for_key(provider.auth_state_key) + } + } _ => self.state_for_key(provider.auth_state_key), } } diff --git a/src/auth/tests.rs b/src/auth/tests.rs index 2df34a74f..fb7bc37e5 100644 --- a/src/auth/tests.rs +++ b/src/auth/tests.rs @@ -452,11 +452,13 @@ fn copilot_recent_token_exchange_failure_is_not_auto_usable() { let prev_copilot_token = std::env::var_os("COPILOT_GITHUB_TOKEN"); let prev_gh_token = std::env::var_os("GH_TOKEN"); let prev_github_token = std::env::var_os("GITHUB_TOKEN"); + let prev_allow_github_env = std::env::var_os("JCODE_COPILOT_ALLOW_GITHUB_ENV"); crate::env::set_var("JCODE_HOME", temp.path()); crate::env::remove_var("COPILOT_GITHUB_TOKEN"); crate::env::remove_var("GH_TOKEN"); crate::env::remove_var("GITHUB_TOKEN"); + crate::env::remove_var("JCODE_COPILOT_ALLOW_GITHUB_ENV"); AuthStatus::invalidate_cache(); crate::auth::copilot::invalidate_github_token_cache(); @@ -490,6 +492,13 @@ fn copilot_recent_token_exchange_failure_is_not_auto_usable() { AuthStatus::invalidate_cache(); crate::auth::copilot::invalidate_github_token_cache(); let status = AuthStatus::check_fast(); + assert_eq!(status.copilot, AuthState::Expired); + assert!(!status.copilot_has_api_token); + + crate::env::set_var("JCODE_COPILOT_ALLOW_GITHUB_ENV", "1"); + AuthStatus::invalidate_cache(); + crate::auth::copilot::invalidate_github_token_cache(); + let status = AuthStatus::check_fast(); assert_eq!(status.copilot, AuthState::Available); assert!(status.copilot_has_api_token); @@ -497,6 +506,57 @@ fn copilot_recent_token_exchange_failure_is_not_auto_usable() { restore_env_var("COPILOT_GITHUB_TOKEN", prev_copilot_token); restore_env_var("GH_TOKEN", prev_gh_token); restore_env_var("GITHUB_TOKEN", prev_github_token); + restore_env_var("JCODE_COPILOT_ALLOW_GITHUB_ENV", prev_allow_github_env); + AuthStatus::invalidate_cache(); + crate::auth::copilot::invalidate_github_token_cache(); +} + +#[test] +fn copilot_does_not_auto_use_generic_github_env_without_opt_in() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create temp dir"); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&home).expect("create temp home"); + std::fs::create_dir_all(&xdg).expect("create temp xdg config"); + + let saved = [ + "JCODE_HOME", + "HOME", + "XDG_CONFIG_HOME", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "JCODE_COPILOT_ALLOW_GITHUB_ENV", + ] + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect::>(); + + crate::env::set_var("JCODE_HOME", temp.path().join("jcode-home")); + crate::env::set_var("HOME", &home); + crate::env::set_var("XDG_CONFIG_HOME", &xdg); + crate::env::remove_var("COPILOT_GITHUB_TOKEN"); + crate::env::set_var("GH_TOKEN", "gho_generic_github_token"); + crate::env::set_var("GITHUB_TOKEN", "ghp_generic_github_token"); + crate::env::remove_var("JCODE_COPILOT_ALLOW_GITHUB_ENV"); + + AuthStatus::invalidate_cache(); + crate::auth::copilot::invalidate_github_token_cache(); + let status = AuthStatus::check_fast(); + assert_eq!(status.copilot, AuthState::NotConfigured); + assert!(!status.copilot_has_api_token); + + crate::env::set_var("JCODE_COPILOT_ALLOW_GITHUB_ENV", "1"); + AuthStatus::invalidate_cache(); + crate::auth::copilot::invalidate_github_token_cache(); + let status = AuthStatus::check_fast(); + assert_eq!(status.copilot, AuthState::Available); + assert!(status.copilot_has_api_token); + + for (key, value) in saved { + restore_env_var(key, value); + } AuthStatus::invalidate_cache(); crate::auth::copilot::invalidate_github_token_cache(); } diff --git a/src/cli/auth_test/run.rs b/src/cli/auth_test/run.rs index 60cf21289..9b453d447 100644 --- a/src/cli/auth_test/run.rs +++ b/src/cli/auth_test/run.rs @@ -294,7 +294,9 @@ pub(crate) fn resolve_auth_test_targets( pub(crate) fn configured_auth_test_targets( status: &crate::auth::AuthStatus, ) -> Vec { - crate::provider_catalog::auth_status_login_providers() + crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::auth_status_login_providers(), + ) .into_iter() .filter(|provider| status.assessment_for_provider(*provider).is_configured()) .filter_map(ResolvedAuthTestTarget::from_provider) diff --git a/src/cli/commands/report_info.rs b/src/cli/commands/report_info.rs index 8d2cb6a45..8292758fc 100644 --- a/src/cli/commands/report_info.rs +++ b/src/cli/commands/report_info.rs @@ -159,7 +159,9 @@ pub(super) fn run_auth_status_command(emit_json: bool) -> Result<()> { fn build_auth_status_report() -> AuthStatusReport { let status = crate::auth::AuthStatus::check(); let validation = crate::auth::validation::load_all(); - let providers = crate::provider_catalog::auth_status_login_providers(); + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::auth_status_login_providers(), + ); let reports = providers .into_iter() .map(|provider| { @@ -526,12 +528,17 @@ fn select_auth_doctor_providers( return Ok(vec![provider]); } - let configured = crate::provider_catalog::auth_status_login_providers() + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::auth_status_login_providers(), + ); + let configured = providers + .iter() + .copied() .into_iter() .filter(|provider| status.assessment_for_provider(*provider).is_configured()) .collect::>(); if configured.is_empty() { - Ok(crate::provider_catalog::auth_status_login_providers().to_vec()) + Ok(providers) } else { Ok(configured) } diff --git a/src/config.rs b/src/config.rs index ef03a1ae1..77eca23a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -152,6 +152,9 @@ pub struct Config { /// Provider configuration pub provider: ProviderConfig, + /// Policy-level overrides. + pub policy: PolicyConfig, + /// Named provider profiles, keyed by profile name. /// /// Example: @@ -183,6 +186,23 @@ pub struct Config { pub autojudge: AutoJudgeConfig, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PolicyConfig { + /// Providers excluded from auth probes, auto-selection, fallback, and model routing. + pub disabled_providers: Vec, +} + +impl Config { + pub fn disabled_provider_entries(&self) -> impl Iterator { + self.provider + .disabled_providers + .iter() + .chain(self.policy.disabled_providers.iter()) + .map(String::as_str) + } +} + /// External dictation / speech-to-text integration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/config/default_file.rs b/src/config/default_file.rs index 7d84e618c..118faad9d 100644 --- a/src/config/default_file.rs +++ b/src/config/default_file.rs @@ -155,6 +155,8 @@ update_channel = "stable" # Default provider (optional: claude|openai|copilot|openrouter) # When set, this provider is preferred on startup if available # default_provider = "copilot" +# Providers excluded from auto-selection, failover, auth surfaces, and model routing +# disabled_providers = ["copilot"] # OpenAI reasoning effort (none|low|medium|high|xhigh) openai_reasoning_effort = "low" # OpenAI transport mode (auto|websocket|https) diff --git a/src/config/env_overrides.rs b/src/config/env_overrides.rs index 8ca531767..28e6fe76c 100644 --- a/src/config/env_overrides.rs +++ b/src/config/env_overrides.rs @@ -382,6 +382,14 @@ impl Config { self.provider.default_provider = Some(trimmed); } } + if let Ok(v) = std::env::var("JCODE_DISABLED_PROVIDERS") { + self.provider.disabled_providers = v + .split(',') + .map(str::trim) + .filter(|provider| !provider.is_empty()) + .map(|provider| provider.to_ascii_lowercase()) + .collect(); + } if let Ok(v) = std::env::var("JCODE_OPENAI_REASONING_EFFORT") { let trimmed = v.trim().to_string(); if !trimmed.is_empty() { diff --git a/src/config_tests.rs b/src/config_tests.rs index c0ff4d748..4c8df95ee 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -280,6 +280,27 @@ fn test_env_override_trusted_external_auth_splits_source_and_path_entries() { } } +#[test] +fn test_env_override_disabled_providers_normalizes_entries() { + let _guard = crate::storage::lock_test_env(); + let prev = std::env::var_os("JCODE_DISABLED_PROVIDERS"); + crate::env::set_var("JCODE_DISABLED_PROVIDERS", " Copilot, OPENAI ,, gemini "); + + let mut cfg = Config::default(); + cfg.apply_env_overrides(); + + assert_eq!( + cfg.provider.disabled_providers, + vec!["copilot", "openai", "gemini"] + ); + + if let Some(prev) = prev { + crate::env::set_var("JCODE_DISABLED_PROVIDERS", prev); + } else { + crate::env::remove_var("JCODE_DISABLED_PROVIDERS"); + } +} + #[test] fn test_external_auth_source_allowed_for_path_matches_saved_entry() { let _guard = crate::storage::lock_test_env(); diff --git a/src/provider/accessors.rs b/src/provider/accessors.rs index ef366bf36..99dc84064 100644 --- a/src/provider/accessors.rs +++ b/src/provider/accessors.rs @@ -69,6 +69,9 @@ impl MultiProvider { } pub(super) fn provider_slot_available(&self, provider: ActiveProvider) -> bool { + if Self::provider_is_disabled(provider) { + return false; + } match provider { ActiveProvider::Claude => self.has_claude_runtime(), ActiveProvider::OpenAI => self.openai_provider().is_some(), diff --git a/src/provider/failover.rs b/src/provider/failover.rs index c8ead4505..0ae70938a 100644 --- a/src/provider/failover.rs +++ b/src/provider/failover.rs @@ -3,6 +3,9 @@ use jcode_provider_core::{FailoverDecision, ProviderFailoverPrompt}; impl MultiProvider { pub(super) fn provider_is_configured(&self, provider: ActiveProvider) -> bool { + if Self::provider_is_disabled(provider) { + return false; + } self.reconcile_auth_if_provider_missing(provider) } @@ -25,7 +28,7 @@ impl MultiProvider { if let Some(forced) = forced_provider { vec![forced] } else { - Self::fallback_sequence(active) + Self::enabled_fallback_sequence(active) } } diff --git a/src/provider/mod.rs b/src/provider/mod.rs index c973e898f..eaacbdf6f 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -374,6 +374,12 @@ impl MultiProvider { if model.is_empty() { anyhow::bail!("Model cannot be empty"); } + if Self::provider_is_disabled(provider) { + anyhow::bail!( + "{} provider is disabled by [provider].disabled_providers", + Self::provider_label(provider) + ); + } self.reconcile_auth_if_provider_missing(provider); @@ -520,7 +526,7 @@ impl MultiProvider { // using cheap local probes to hot-initialize newly configured providers. crate::auth::AuthStatus::invalidate_cache(); - if self.use_claude_cli { + if !Self::provider_is_disabled(ActiveProvider::Claude) && self.use_claude_cli { if self.claude_provider().is_none() && crate::auth::claude::load_credentials().is_ok() { crate::logging::info("Hot-initialized Claude CLI provider after auth change"); *self @@ -529,7 +535,8 @@ impl MultiProvider { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(claude::ClaudeProvider::new())); } - } else if self.anthropic_provider().is_none() + } else if !Self::provider_is_disabled(ActiveProvider::Claude) + && self.anthropic_provider().is_none() && crate::auth::claude::load_credentials().is_ok() { crate::logging::info("Hot-initialized Anthropic provider after auth change"); @@ -540,7 +547,12 @@ impl MultiProvider { Some(Arc::new(anthropic::AnthropicProvider::new())); } - if let Some(openai) = self.openai_provider() { + if Self::provider_is_disabled(ActiveProvider::OpenAI) { + *self + .openai + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } else if let Some(openai) = self.openai_provider() { openai.reload_credentials_now(); } else if let Ok(credentials) = crate::auth::codex::load_credentials() { crate::logging::info("Hot-initialized OpenAI provider after auth change"); @@ -551,7 +563,9 @@ impl MultiProvider { Some(Arc::new(openai::OpenAIProvider::new(credentials))); } - if openrouter::OpenRouterProvider::has_credentials() { + if !Self::provider_is_disabled(ActiveProvider::OpenRouter) + && openrouter::OpenRouterProvider::has_credentials() + { match openrouter::OpenRouterProvider::new() { Ok(provider) => { let should_install = if preserve_existing_openrouter_profile { @@ -591,7 +605,7 @@ impl MultiProvider { } let already_has = self.copilot_provider().is_some(); - if !already_has { + if !Self::provider_is_disabled(ActiveProvider::Copilot) && !already_has { let status = crate::auth::AuthStatus::check_fast(); if status.copilot_has_api_token { match copilot::CopilotApiProvider::new() { @@ -618,7 +632,10 @@ impl MultiProvider { } let already_has_antigravity = self.antigravity_provider().is_some(); - if !already_has_antigravity && crate::auth::antigravity::load_tokens().is_ok() { + if !Self::provider_is_disabled(ActiveProvider::Antigravity) + && !already_has_antigravity + && crate::auth::antigravity::load_tokens().is_ok() + { crate::logging::info("Hot-initialized Antigravity provider after login"); *self .antigravity @@ -628,7 +645,10 @@ impl MultiProvider { } let already_has_gemini = self.gemini_provider().is_some(); - if !already_has_gemini && crate::auth::gemini::load_tokens().is_ok() { + if !Self::provider_is_disabled(ActiveProvider::Gemini) + && !already_has_gemini + && crate::auth::gemini::load_tokens().is_ok() + { crate::logging::info("Hot-initialized Gemini provider after login"); *self .gemini @@ -638,7 +658,8 @@ impl MultiProvider { } let already_has_cursor = self.cursor_provider().is_some(); - if !already_has_cursor + if !Self::provider_is_disabled(ActiveProvider::Cursor) + && !already_has_cursor && crate::auth::AuthStatus::check_fast() .assessment_for_provider(crate::provider_catalog::CURSOR_LOGIN_PROVIDER) .is_available() @@ -652,7 +673,10 @@ impl MultiProvider { } let already_has_bedrock = self.bedrock_provider().is_some(); - if !already_has_bedrock && bedrock::BedrockProvider::has_credentials() { + if !Self::provider_is_disabled(ActiveProvider::Bedrock) + && !already_has_bedrock + && bedrock::BedrockProvider::has_credentials() + { crate::logging::info("Hot-initialized AWS Bedrock provider after login"); *self .bedrock @@ -1021,7 +1045,15 @@ impl Provider for MultiProvider { let mut openrouter_endpoint_cache_hits = 0usize; let mut openrouter_endpoint_routes = 0usize; let mut openrouter_scheduled_endpoint_refreshes = 0usize; - let has_oauth = self.has_claude_runtime(); + let claude_enabled = !Self::provider_is_disabled(ActiveProvider::Claude); + let openai_enabled = !Self::provider_is_disabled(ActiveProvider::OpenAI); + let copilot_enabled = !Self::provider_is_disabled(ActiveProvider::Copilot); + let antigravity_enabled = !Self::provider_is_disabled(ActiveProvider::Antigravity); + let gemini_enabled = !Self::provider_is_disabled(ActiveProvider::Gemini); + let cursor_enabled = !Self::provider_is_disabled(ActiveProvider::Cursor); + let bedrock_enabled = !Self::provider_is_disabled(ActiveProvider::Bedrock); + let openrouter_enabled = !Self::provider_is_disabled(ActiveProvider::OpenRouter); + let has_oauth = claude_enabled && self.has_claude_runtime(); let has_api_key = std::env::var("ANTHROPIC_API_KEY").is_ok(); let anthropic_models = if let Some(anthropic) = self.anthropic_provider() { anthropic.available_models_for_switching() @@ -1037,114 +1069,122 @@ impl Provider for MultiProvider { }; // Anthropic models (oauth and/or api-key) - for model in anthropic_models { - let (available, detail) = if has_oauth && !has_api_key { - anthropic_oauth_route_availability(&model) - } else { - (true, String::new()) - }; + if claude_enabled { + for model in anthropic_models { + let (available, detail) = if has_oauth && !has_api_key { + anthropic_oauth_route_availability(&model) + } else { + (true, String::new()) + }; - if has_oauth { - routes.push(build_anthropic_oauth_route( - &model, - available, - detail.clone(), - )); - } - if has_api_key { - let (ak_available, ak_detail) = anthropic_api_key_route_availability(&model); - routes.push(ModelRoute { - model: model.to_string(), - provider: "Anthropic".to_string(), - api_method: "api-key".to_string(), - available: ak_available, - detail: ak_detail, - cheapness: cheapness_for_route(&model, "Anthropic", "api-key"), - }); - } - if !has_oauth && !has_api_key { - routes.push(ModelRoute { - model: model.to_string(), - provider: "Anthropic".to_string(), - api_method: "claude-oauth".to_string(), - available: false, - detail: "no credentials".to_string(), - cheapness: cheapness_for_route(&model, "Anthropic", "claude-oauth"), - }); + if has_oauth { + routes.push(build_anthropic_oauth_route( + &model, + available, + detail.clone(), + )); + } + if has_api_key { + let (ak_available, ak_detail) = anthropic_api_key_route_availability(&model); + routes.push(ModelRoute { + model: model.to_string(), + provider: "Anthropic".to_string(), + api_method: "api-key".to_string(), + available: ak_available, + detail: ak_detail, + cheapness: cheapness_for_route(&model, "Anthropic", "api-key"), + }); + } + if !has_oauth && !has_api_key { + routes.push(ModelRoute { + model: model.to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: false, + detail: "no credentials".to_string(), + cheapness: cheapness_for_route(&model, "Anthropic", "claude-oauth"), + }); + } } } // OpenAI models - let openai_auth = crate::auth::AuthStatus::check_fast(); - for model in openai_models { - let availability = model_availability_for_account(&model); - let (available, detail) = if self.openai_provider().is_none() { - (false, "no credentials".to_string()) - } else { - match availability.state { - AccountModelAvailabilityState::Available => (true, String::new()), - AccountModelAvailabilityState::Unavailable => ( - false, - format_account_model_availability_detail(&availability) - .unwrap_or_else(|| "not available".to_string()), - ), - AccountModelAvailabilityState::Unknown => { - let detail = format_account_model_availability_detail(&availability) - .unwrap_or_else(|| "availability unknown".to_string()); - (true, detail) + if openai_enabled { + let openai_auth = crate::auth::AuthStatus::check_fast(); + for model in openai_models { + let availability = model_availability_for_account(&model); + let (available, detail) = if self.openai_provider().is_none() { + (false, "no credentials".to_string()) + } else { + match availability.state { + AccountModelAvailabilityState::Available => (true, String::new()), + AccountModelAvailabilityState::Unavailable => ( + false, + format_account_model_availability_detail(&availability) + .unwrap_or_else(|| "not available".to_string()), + ), + AccountModelAvailabilityState::Unknown => { + let detail = format_account_model_availability_detail(&availability) + .unwrap_or_else(|| "availability unknown".to_string()); + (true, detail) + } } + }; + if openai_auth.openai_has_oauth { + routes.push(build_openai_oauth_route(&model, available, detail.clone())); + } + if openai_auth.openai_has_api_key { + routes.push(build_openai_api_key_route( + &model, + self.openai_provider().is_some(), + String::new(), + )); + } + if !openai_auth.openai_has_oauth && !openai_auth.openai_has_api_key { + routes.push(build_openai_oauth_route(&model, false, detail)); } - }; - if openai_auth.openai_has_oauth { - routes.push(build_openai_oauth_route(&model, available, detail.clone())); - } - if openai_auth.openai_has_api_key { - routes.push(build_openai_api_key_route( - &model, - self.openai_provider().is_some(), - String::new(), - )); - } - if !openai_auth.openai_has_oauth && !openai_auth.openai_has_api_key { - routes.push(build_openai_oauth_route(&model, false, detail)); } } let mut added_direct_openai_compatible_routes = false; - for profile in crate::provider_catalog::openai_compatible_profiles() - .iter() - .copied() - { - if !crate::provider_catalog::openai_compatible_profile_is_configured(profile) { - continue; - } - let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); - let api_method = format!("openai-compatible:{}", resolved.id); - for model in crate::provider_catalog::openai_compatible_profile_static_models(profile) { - let already_present = routes.iter().any(|route| { - route.model == model - && route.provider == resolved.display_name - && (route.api_method == "openai-compatible" - || route.api_method == api_method) - }); - if already_present { - added_direct_openai_compatible_routes = true; + if openrouter_enabled { + for profile in crate::provider_catalog::openai_compatible_profiles() + .iter() + .copied() + { + if !crate::provider_catalog::openai_compatible_profile_is_configured(profile) { continue; } - routes.push(ModelRoute { - model, - provider: resolved.display_name.clone(), - api_method: api_method.clone(), - available: true, - detail: resolved.api_base.clone(), - cheapness: None, - }); - added_direct_openai_compatible_routes = true; + let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); + let api_method = format!("openai-compatible:{}", resolved.id); + for model in + crate::provider_catalog::openai_compatible_profile_static_models(profile) + { + let already_present = routes.iter().any(|route| { + route.model == model + && route.provider == resolved.display_name + && (route.api_method == "openai-compatible" + || route.api_method == api_method) + }); + if already_present { + added_direct_openai_compatible_routes = true; + continue; + } + routes.push(ModelRoute { + model, + provider: resolved.display_name.clone(), + api_method: api_method.clone(), + available: true, + detail: resolved.api_base.clone(), + cheapness: None, + }); + added_direct_openai_compatible_routes = true; + } } } // GitHub Copilot models - { + if copilot_enabled { if let Some(copilot) = self.copilot_provider() { let copilot_models = copilot.available_models_display(); let detail = copilot.model_catalog_detail(); @@ -1165,7 +1205,7 @@ impl Provider for MultiProvider { } // Gemini models - { + if gemini_enabled { if let Some(gemini) = self.gemini_provider() { for model in gemini.available_models_display() { routes.push(ModelRoute { @@ -1181,14 +1221,14 @@ impl Provider for MultiProvider { } // Antigravity models - { + if antigravity_enabled { if let Some(antigravity) = self.antigravity_provider() { routes.extend(antigravity.model_routes()); } } // Cursor models - { + if cursor_enabled { if let Some(cursor) = self.cursor_provider() { for model in cursor.available_models_display() { routes.push(ModelRoute { @@ -1204,7 +1244,7 @@ impl Provider for MultiProvider { } // AWS Bedrock models and inference profiles - { + if bedrock_enabled { if let Some(bedrock) = self.bedrock_provider() { routes.extend(bedrock.model_routes()); } else if bedrock::BedrockProvider::has_credentials() { @@ -1221,7 +1261,9 @@ impl Provider for MultiProvider { } // OpenRouter models (with per-provider endpoints) - let openrouter_provider = self.openrouter_provider(); + let openrouter_provider = openrouter_enabled + .then(|| self.openrouter_provider()) + .flatten(); let has_openrouter = openrouter_provider.is_some(); let has_openrouter_provider_features = openrouter_provider .as_ref() diff --git a/src/provider/selection.rs b/src/provider/selection.rs index 0bc3b613d..2893c06ab 100644 --- a/src/provider/selection.rs +++ b/src/provider/selection.rs @@ -73,6 +73,24 @@ impl MultiProvider { jcode_provider_core::provider_key(provider) } + pub(super) fn provider_is_disabled(provider: ActiveProvider) -> bool { + crate::config::config() + .disabled_provider_entries() + .any(|raw| { + let value = raw.trim(); + !value.is_empty() + && (Self::parse_provider_hint(value) == Some(provider) + || value.eq_ignore_ascii_case(Self::provider_key(provider))) + }) + } + + pub(super) fn enabled_fallback_sequence(active: ActiveProvider) -> Vec { + Self::fallback_sequence(active) + .into_iter() + .filter(|provider| !Self::provider_is_disabled(*provider)) + .collect() + } + pub(super) fn set_active_provider(&self, provider: ActiveProvider) { *self .active diff --git a/src/provider/startup.rs b/src/provider/startup.rs index 086ff6421..1a8781fd4 100644 --- a/src/provider/startup.rs +++ b/src/provider/startup.rs @@ -105,6 +105,14 @@ impl MultiProvider { .is_available(); let has_bedrock_creds = bedrock::BedrockProvider::has_credentials(); let has_openrouter_creds = openrouter::OpenRouterProvider::has_credentials(); + let claude_enabled = !Self::provider_is_disabled(ActiveProvider::Claude); + let openai_enabled = !Self::provider_is_disabled(ActiveProvider::OpenAI); + let copilot_enabled = !Self::provider_is_disabled(ActiveProvider::Copilot); + let antigravity_enabled = !Self::provider_is_disabled(ActiveProvider::Antigravity); + let gemini_enabled = !Self::provider_is_disabled(ActiveProvider::Gemini); + let cursor_enabled = !Self::provider_is_disabled(ActiveProvider::Cursor); + let bedrock_enabled = !Self::provider_is_disabled(ActiveProvider::Bedrock); + let openrouter_enabled = !Self::provider_is_disabled(ActiveProvider::OpenRouter); let use_claude_cli = std::env::var("JCODE_USE_CLAUDE_CLI") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) @@ -115,7 +123,7 @@ impl MultiProvider { ); } - let claude = if has_claude_creds && use_claude_cli { + let claude = if claude_enabled && has_claude_creds && use_claude_cli { crate::logging::info( "Using deprecated Claude CLI provider (forced by JCODE_USE_CLAUDE_CLI=1)", ); @@ -124,13 +132,13 @@ impl MultiProvider { None }; - let anthropic = if has_claude_creds && !use_claude_cli { + let anthropic = if claude_enabled && has_claude_creds && !use_claude_cli { Some(Arc::new(anthropic::AnthropicProvider::new())) } else { None }; - let openai = if has_openai_creds { + let openai = if openai_enabled && has_openai_creds { auth::codex::load_credentials() .ok() .map(openai::OpenAIProvider::new) @@ -139,7 +147,7 @@ impl MultiProvider { None }; - let copilot_api = if has_copilot_api { + let copilot_api = if copilot_enabled && has_copilot_api { let copilot_init_start = std::time::Instant::now(); match copilot::CopilotApiProvider::new() { Ok(p) => { @@ -170,31 +178,31 @@ impl MultiProvider { None }; - let antigravity_provider = if has_antigravity_creds { + let antigravity_provider = if antigravity_enabled && has_antigravity_creds { Some(Arc::new(antigravity::AntigravityProvider::new())) } else { None }; - let gemini_provider = if has_gemini_creds { + let gemini_provider = if gemini_enabled && has_gemini_creds { Some(Arc::new(gemini::GeminiProvider::new())) } else { None }; - let cursor_provider = if has_cursor_creds { + let cursor_provider = if cursor_enabled && has_cursor_creds { Some(Arc::new(cursor::CursorCliProvider::new())) } else { None }; - let bedrock_provider = if has_bedrock_creds { + let bedrock_provider = if bedrock_enabled && has_bedrock_creds { Some(Arc::new(bedrock::BedrockProvider::new())) } else { None }; - let openrouter = if has_openrouter_creds { + let openrouter = if openrouter_enabled && has_openrouter_creds { let named_profile = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") .ok() .or_else(|| default_named_provider_profile.clone()); @@ -226,14 +234,14 @@ impl MultiProvider { Some("0") ); let availability = ProviderAvailability { - openai: openai.is_some(), - claude: claude.is_some() || anthropic.is_some(), - copilot: copilot_api.is_some(), - antigravity: antigravity_provider.is_some(), - gemini: gemini_provider.is_some(), - cursor: cursor_provider.is_some(), - bedrock: bedrock_provider.is_some(), - openrouter: openrouter.is_some(), + openai: openai_enabled && openai.is_some(), + claude: claude_enabled && (claude.is_some() || anthropic.is_some()), + copilot: copilot_enabled && copilot_api.is_some(), + antigravity: antigravity_enabled && antigravity_provider.is_some(), + gemini: gemini_enabled && gemini_provider.is_some(), + cursor: cursor_enabled && cursor_provider.is_some(), + bedrock: bedrock_enabled && bedrock_provider.is_some(), + openrouter: openrouter_enabled && openrouter.is_some(), copilot_premium_zero, }; let mut active = Self::auto_default_provider(availability); @@ -246,9 +254,14 @@ impl MultiProvider { let forced_provider = Self::forced_provider_from_env(); if let Some(forced) = forced_provider { - active = forced; let is_configured = availability.is_configured(forced); - if is_configured { + if Self::provider_is_disabled(forced) { + crate::logging::warn(&format!( + "Forced provider '{}' is disabled by config; using auto-detected default", + Self::provider_key(forced) + )); + } else if is_configured { + active = forced; let display = if matches!(forced, ActiveProvider::OpenRouter) { crate::provider_catalog::active_openai_compatible_display_name() .unwrap_or_else(|| Self::provider_key(forced).to_string()) @@ -264,6 +277,7 @@ impl MultiProvider { "Forced provider '{}' is not configured; requests will fail until credentials are available", Self::provider_key(forced) )); + active = forced; } } else if let Some(pref) = provider_state.default_provider_key() { if let Some(selection) = provider_state.default_provider_selection() { diff --git a/src/provider/tests/fallback_failover.rs b/src/provider/tests/fallback_failover.rs index 08cc12a5a..79cba89ff 100644 --- a/src/provider/tests/fallback_failover.rs +++ b/src/provider/tests/fallback_failover.rs @@ -166,6 +166,32 @@ fn test_forced_provider_disables_cross_provider_fallback_sequence() { ); } +#[test] +fn test_enabled_fallback_sequence_omits_disabled_providers() { + let _guard = crate::storage::lock_test_env(); + let previous = std::env::var_os("JCODE_DISABLED_PROVIDERS"); + crate::env::set_var("JCODE_DISABLED_PROVIDERS", "copilot,openrouter"); + crate::config::invalidate_config_cache(); + + assert_eq!( + MultiProvider::enabled_fallback_sequence(ActiveProvider::Claude), + vec![ + ActiveProvider::Claude, + ActiveProvider::OpenAI, + ActiveProvider::Gemini, + ActiveProvider::Cursor, + ActiveProvider::Bedrock, + ] + ); + + if let Some(previous) = previous { + crate::env::set_var("JCODE_DISABLED_PROVIDERS", previous); + } else { + crate::env::remove_var("JCODE_DISABLED_PROVIDERS"); + } + crate::config::invalidate_config_cache(); +} + #[test] fn test_set_model_rejects_cross_provider_without_creds() { let _guard = crate::storage::lock_test_env(); diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 61931f1f1..75044bd24 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -145,6 +145,47 @@ pub fn runtime_provider_display_name(provider_name: &str) -> String { } } +pub fn login_provider_disabled(provider: LoginProviderDescriptor) -> bool { + let Some(key) = login_provider_disable_key(provider) else { + return false; + }; + + crate::config::config() + .disabled_provider_entries() + .any(|raw| { + let value = raw.trim(); + value.eq_ignore_ascii_case(key) + || (key == "copilot" && value.eq_ignore_ascii_case("github copilot")) + }) +} + +pub fn filter_disabled_login_providers( + providers: impl IntoIterator, +) -> Vec { + providers + .into_iter() + .filter(|provider| !login_provider_disabled(*provider)) + .collect() +} + +fn login_provider_disable_key(provider: LoginProviderDescriptor) -> Option<&'static str> { + match provider.target { + LoginProviderTarget::Claude => Some("claude"), + LoginProviderTarget::OpenAi | LoginProviderTarget::OpenAiApiKey => Some("openai"), + LoginProviderTarget::OpenRouter => Some("openrouter"), + LoginProviderTarget::Bedrock => Some("bedrock"), + LoginProviderTarget::OpenAiCompatible(profile) => Some(profile.id), + LoginProviderTarget::Cursor => Some("cursor"), + LoginProviderTarget::Copilot => Some("copilot"), + LoginProviderTarget::Gemini => Some("gemini"), + LoginProviderTarget::Antigravity => Some("antigravity"), + LoginProviderTarget::AutoImport + | LoginProviderTarget::Jcode + | LoginProviderTarget::Azure + | LoginProviderTarget::Google => None, + } +} + pub fn openai_compatible_profile_by_id(id: &str) -> Option { let normalized = id.trim().to_ascii_lowercase(); openai_compatible_profiles() diff --git a/src/provider_catalog_tests.rs b/src/provider_catalog_tests.rs index 4c4de00ed..9a6d1085a 100644 --- a/src/provider_catalog_tests.rs +++ b/src/provider_catalog_tests.rs @@ -147,6 +147,19 @@ fn auth_issue_runtime_display_name_tracks_direct_compatible_profiles() { assert_eq!(runtime_provider_display_name("openrouter"), "Z.AI"); } +#[test] +fn disabled_login_providers_are_filtered_from_user_facing_lists() { + let _lock = crate::storage::lock_test_env(); + let _guard = EnvGuard::save(&["JCODE_DISABLED_PROVIDERS"]); + crate::env::set_var("JCODE_DISABLED_PROVIDERS", "copilot,github copilot"); + crate::config::invalidate_config_cache(); + + let providers = filter_disabled_login_providers(login_providers().iter().copied()); + assert!(!providers.iter().any(|provider| provider.id == "copilot")); + + crate::config::invalidate_config_cache(); +} + #[test] fn auth_profile_env_application_flushes_stale_openrouter_catalog_state() { let _lock = crate::storage::lock_test_env(); diff --git a/src/tui/app/auth.rs b/src/tui/app/auth.rs index f0cb30830..e0457e252 100644 --- a/src/tui/app/auth.rs +++ b/src/tui/app/auth.rs @@ -139,7 +139,9 @@ impl App { crate::auth::AuthState::Expired => "needs attention", crate::auth::AuthState::NotConfigured => "not configured", }; - let providers = crate::provider_catalog::auth_status_login_providers(); + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::auth_status_login_providers(), + ); let mut message = String::from( "**Authentication Status:**\n\n| Provider | Status | Method | Health | Validation |\n|----------|--------|--------|--------|------------|\n", ); diff --git a/src/tui/app/auth_account_commands.rs b/src/tui/app/auth_account_commands.rs index e4b94c0fb..0cce71e73 100644 --- a/src/tui/app/auth_account_commands.rs +++ b/src/tui/app/auth_account_commands.rs @@ -23,7 +23,9 @@ pub(crate) fn handle_auth_command(app: &mut App, trimmed: &str) -> bool { .strip_prefix("/login ") .or_else(|| trimmed.strip_prefix("/auth ")) { - let providers = crate::provider_catalog::tui_login_providers(); + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::tui_login_providers(), + ); if let Some(provider) = crate::provider_catalog::resolve_login_selection(provider, &providers) { @@ -967,12 +969,17 @@ fn render_auth_doctor_markdown(provider_filter: Option<&str>) -> String { } }, None => { - let configured = crate::provider_catalog::auth_status_login_providers() + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::auth_status_login_providers(), + ); + let configured = providers + .iter() + .copied() .into_iter() .filter(|provider| status.assessment_for_provider(*provider).is_configured()) .collect::>(); if configured.is_empty() { - crate::provider_catalog::auth_status_login_providers().to_vec() + providers } else { configured } diff --git a/src/tui/app/auth_account_picker.rs b/src/tui/app/auth_account_picker.rs index 7db0e5603..23b7a2cde 100644 --- a/src/tui/app/auth_account_picker.rs +++ b/src/tui/app/auth_account_picker.rs @@ -21,7 +21,9 @@ impl App { return; } }, - None => crate::provider_catalog::login_providers().to_vec(), + None => crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::login_providers().iter().copied(), + ), }; let mut items = Vec::new(); diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index eb5f88bd7..a726fed8b 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -316,7 +316,7 @@ impl App { let routes_started = std::time::Instant::now(); let routes: Vec = if self.is_remote { if !self.remote_model_options.is_empty() { - self.remote_model_options.clone() + self.visible_remote_model_options() } else { self.build_remote_model_routes_fallback() } @@ -904,10 +904,16 @@ impl App { pub(super) fn build_remote_model_routes_fallback(&self) -> Vec { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); + let copilot_disabled = crate::provider_catalog::login_provider_disabled( + crate::provider_catalog::COPILOT_LOGIN_PROVIDER, + ); for model in &self.remote_available_entries { if !crate::provider::is_listable_model_name(model) { continue; } + if copilot_disabled && Self::remote_model_should_offer_copilot_route(model) { + continue; + } let openrouter_catalog_model = crate::provider::openrouter_catalog_model_id(model); let openrouter_cached = openrouter_catalog_model @@ -1045,7 +1051,10 @@ impl App { added_any = true; } - if Self::remote_model_should_offer_copilot_route(model) && !model.contains("[1m]") { + if !copilot_disabled + && Self::remote_model_should_offer_copilot_route(model) + && !model.contains("[1m]") + { routes.push(crate::provider::build_copilot_route( model, auth.copilot == crate::auth::AuthState::Available @@ -1081,6 +1090,34 @@ impl App { routes } + pub(super) fn visible_remote_model_options(&self) -> Vec { + self.remote_model_options + .iter() + .filter(|route| !Self::model_route_disabled_by_config(route)) + .cloned() + .collect() + } + + pub(super) fn visible_remote_available_entries(&self) -> Vec { + let copilot_disabled = crate::provider_catalog::login_provider_disabled( + crate::provider_catalog::COPILOT_LOGIN_PROVIDER, + ); + self.remote_available_entries + .iter() + .filter(|model| { + !(copilot_disabled && Self::remote_model_should_offer_copilot_route(model)) + }) + .cloned() + .collect() + } + + fn model_route_disabled_by_config(route: &crate::provider::ModelRoute) -> bool { + route.api_method.eq_ignore_ascii_case("copilot") + && crate::provider_catalog::login_provider_disabled( + crate::provider_catalog::COPILOT_LOGIN_PROVIDER, + ) + } + pub(super) fn remote_model_should_offer_copilot_route(model: &str) -> bool { Self::remote_openai_compatible_route_for_model(model).is_none() && (Self::remote_model_is_server_copilot_only(model) diff --git a/src/tui/app/inline_interactive/openers.rs b/src/tui/app/inline_interactive/openers.rs index 9b792373a..0c9da37a0 100644 --- a/src/tui/app/inline_interactive/openers.rs +++ b/src/tui/app/inline_interactive/openers.rs @@ -61,7 +61,9 @@ impl App { pub(crate) fn open_login_picker_inline(&mut self) { let status = crate::auth::AuthStatus::check_fast(); - let providers = crate::provider_catalog::tui_login_providers(); + let providers = crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::tui_login_providers(), + ); let models = providers .into_iter() .map(|provider| { diff --git a/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index 759f1aaec..91e382fe9 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -809,8 +809,8 @@ async fn handle_remote_key_internal( if app_mod::model_context::is_refresh_model_list_command(trimmed) { app.pending_remote_model_refresh_snapshot = Some(( - app.remote_available_entries.clone(), - app.remote_model_options.clone(), + app.visible_remote_available_entries(), + app.visible_remote_model_options(), )); super::super::local::handle_ui_activity( app, diff --git a/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 39f32de65..1a9608fd8 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -313,7 +313,7 @@ impl App { } let routes = if !self.remote_model_options.is_empty() { - self.remote_model_options.clone() + self.visible_remote_model_options() } else { self.build_remote_model_routes_fallback() }; @@ -322,8 +322,8 @@ impl App { push_unique(&mut seen, &mut models, route.model); } - for model in &self.remote_available_entries { - push_unique(&mut seen, &mut models, model.clone()); + for model in self.visible_remote_available_entries() { + push_unique(&mut seen, &mut models, model); } } else { push_unique(&mut seen, &mut models, self.provider.model()); @@ -369,7 +369,7 @@ impl App { if self.is_remote { let routes = if !self.remote_model_options.is_empty() { - self.remote_model_options.clone() + self.visible_remote_model_options() } else { self.build_remote_model_routes_fallback() }; @@ -715,9 +715,11 @@ impl App { suggestions.push(("/auth doctor".into(), "Diagnose provider auth issues")); } suggestions.extend( - crate::provider_catalog::tui_login_providers() - .iter() - .map(|provider| (format!("{} {}", base, provider.id), provider.menu_detail)), + crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::tui_login_providers(), + ) + .iter() + .map(|provider| (format!("{} {}", base, provider.id), provider.menu_detail)), ); return self.rank_suggestions(input, suggestions); } @@ -743,7 +745,9 @@ impl App { "Set custom OpenAI-compatible API base", ), ]; - for provider in crate::provider_catalog::login_providers() { + for provider in crate::provider_catalog::filter_disabled_login_providers( + crate::provider_catalog::login_providers().iter().copied(), + ) { suggestions.push(( format!("/account {}", provider.id), "Open this provider's account/settings actions",