From 88f9913fb75c8918904193c004a674749287133d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 09:07:15 +0000 Subject: [PATCH] Require configured local AI model names Co-authored-by: cooper --- apps/native/src-tauri/src/ai/providers/mod.rs | 38 +++++++++++++------ apps/native/src-tauri/src/evolve/mod.rs | 36 ++++++++++++------ apps/native/src-tauri/src/utils.rs | 21 +++++++++- .../widget/promptinput/prompt-input.tsx | 14 ++++++- .../widget/settings/ai-models-tab.tsx | 16 ++++---- .../src/lib/ai-provider-validation.test.ts | 17 +++++++++ apps/native/src/lib/ai-provider-validation.ts | 10 ++++- 7 files changed, 119 insertions(+), 33 deletions(-) diff --git a/apps/native/src-tauri/src/ai/providers/mod.rs b/apps/native/src-tauri/src/ai/providers/mod.rs index 376e34e07..0fac01061 100644 --- a/apps/native/src-tauri/src/ai/providers/mod.rs +++ b/apps/native/src-tauri/src/ai/providers/mod.rs @@ -62,6 +62,28 @@ pub trait ChatCompletionProvider: Send + Sync { } } +fn configured_model(store_model: Option, env_var: &str) -> Option { + store_model + .and_then(crate::utils::non_empty_trimmed_string) + .or_else(|| { + std::env::var(env_var) + .ok() + .and_then(crate::utils::non_empty_trimmed_string) + }) +} + +fn require_local_model( + provider_name: &str, + store_model: Option, + env_var: &str, +) -> Result { + configured_model(store_model, env_var).ok_or_else(|| { + anyhow::anyhow!( + "No {provider_name} model configured. Please select a model in Settings or set {env_var}." + ) + }) +} + /// Create a provider based on environment configuration pub fn create_provider( app_handle: Option<&AppHandle>, @@ -85,15 +107,12 @@ pub fn create_provider( "codex" => CliTool::Codex, _ => CliTool::OpenCode, }; - let model = store_model - .or_else(|| std::env::var("SUMMARY_MODEL").ok()) - .unwrap_or_else(|| provider.clone()); + let model = + configured_model(store_model, "SUMMARY_MODEL").unwrap_or_else(|| provider.clone()); Ok(Box::new(CliCompletionClient::new(tool, model))) } "ollama" => { - let model = store_model - .or_else(|| std::env::var("SUMMARY_MODEL").ok()) - .unwrap_or_else(|| "llama3.1".to_string()); + let model = require_local_model("Ollama", store_model, "SUMMARY_MODEL")?; let base_url = app_handle .and_then(|app| crate::storage::store::get_ollama_api_base_url(app).ok()) @@ -103,9 +122,7 @@ pub fn create_provider( Ok(Box::new(OllamaClient::new(&base_url, &model))) } "vllm" => { - let model = store_model - .or_else(|| std::env::var("SUMMARY_MODEL").ok()) - .unwrap_or_else(|| "gpt-oss-120b".to_string()); + let model = require_local_model("vLLM", store_model, "SUMMARY_MODEL")?; let base_url = app_handle .and_then(|app| crate::storage::store::get_vllm_api_base_url(app).ok()) @@ -124,8 +141,7 @@ pub fn create_provider( Ok(Box::new(OpenAIClient::new(&api_key, &base_url, &model))) } _ => { - let model = store_model - .or_else(|| std::env::var("SUMMARY_MODEL").ok()) + let model = configured_model(store_model, "SUMMARY_MODEL") .unwrap_or_else(|| DEFAULT_SUMMARY_MODEL.to_string()); let (key, base_url) = if let Some(app) = app_handle { diff --git a/apps/native/src-tauri/src/evolve/mod.rs b/apps/native/src-tauri/src/evolve/mod.rs index 3d9da27ef..badfe1ac6 100644 --- a/apps/native/src-tauri/src/evolve/mod.rs +++ b/apps/native/src-tauri/src/evolve/mod.rs @@ -279,6 +279,26 @@ const BUILD_OUTPUT_TAIL_LINES: usize = 80; const SYSTEM_PROMPT: &str = include_str!("../../prompts/system.md"); +fn configured_model(store_model: Option, env_var: &str) -> Option { + store_model + .and_then(global_utils::non_empty_trimmed_string) + .or_else(|| { + std::env::var(env_var) + .ok() + .and_then(global_utils::non_empty_trimmed_string) + }) +} + +fn require_local_model( + provider_name: &str, + store_model: Option, + env_var: &str, +) -> Result { + configured_model(store_model, env_var).ok_or_else(|| { + anyhow!("No {provider_name} model configured. Please select a model in Settings or set {env_var}.") + }) +} + /// Build a short single-line preview from the conversation messages to help with /// troubleshooting. fn build_preview(messages: &[Message]) -> String { @@ -361,9 +381,7 @@ pub async fn generate_evolution( // Select provider implementation let provider: Arc = if provider_type == "ollama" { - let model = store_model - .or_else(|| std::env::var("EVOLVE_MODEL").ok()) - .unwrap_or_else(|| "qwen3-coder:30b".to_string()); + let model = require_local_model("Ollama", store_model, "EVOLVE_MODEL")?; let base_url = store::get_ollama_api_base_url(app) .ok() .flatten() @@ -380,15 +398,12 @@ pub async fn generate_evolution( "codex" => crate::ai::providers::cli::CliTool::Codex, _ => crate::ai::providers::cli::CliTool::OpenCode, }; - let model = store_model - .or_else(|| std::env::var("EVOLVE_MODEL").ok()) - .unwrap_or_else(|| provider_type.clone()); + let model = + configured_model(store_model, "EVOLVE_MODEL").unwrap_or_else(|| provider_type.clone()); info!("Using CLI provider: {} | Model: {}", provider_type, model); Arc::new(CliProvider::new(tool, model)) } else if provider_type == "vllm" { - let model = store_model - .or_else(|| std::env::var("EVOLVE_MODEL").ok()) - .unwrap_or_else(|| "gpt-oss-120b".to_string()); + let model = require_local_model("vLLM", store_model, "EVOLVE_MODEL")?; let base_url = store::get_vllm_api_base_url(app) .ok() .flatten() @@ -403,8 +418,7 @@ pub async fn generate_evolution( anyhow!("No API key found. Please add your API key in Settings to get started.") })?; - let model = store_model - .or_else(|| std::env::var("EVOLVE_MODEL").ok()) + let model = configured_model(store_model, "EVOLVE_MODEL") .unwrap_or_else(|| DEFAULT_MODEL.to_string()); // Strip OpenRouter-style "openai/" prefix for direct OpenAI usage let model = if base_url == store::OPENAI_BASE_URL { diff --git a/apps/native/src-tauri/src/utils.rs b/apps/native/src-tauri/src/utils.rs index 6d1c8b6ea..170454705 100644 --- a/apps/native/src-tauri/src/utils.rs +++ b/apps/native/src-tauri/src/utils.rs @@ -37,6 +37,15 @@ pub fn normalize_dir_input(input: &str) -> Result { } } +pub fn non_empty_trimmed_string(input: impl AsRef) -> Option { + let trimmed = input.as_ref().trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + // Helper to truncate a string in-place without breaking UTF-8 encoding // and causing a panic, thereby avoiding annoying AI code review comments // about "don't truncate UTF-8 strings". @@ -56,7 +65,7 @@ pub fn truncate_with_ellipsis(s: &str, max: usize) -> String { #[cfg(test)] mod tests { - use super::normalize_dir_input; + use super::{non_empty_trimmed_string, normalize_dir_input}; #[test] fn test_empty_input_returns_err() { @@ -69,6 +78,16 @@ mod tests { assert!(normalize_dir_input("\t\n").is_err()); } + #[test] + fn test_non_empty_trimmed_string_filters_empty_values() { + assert_eq!( + non_empty_trimmed_string(" model-name "), + Some("model-name".to_string()) + ); + assert_eq!(non_empty_trimmed_string(" "), None); + assert_eq!(non_empty_trimmed_string(""), None); + } + #[test] fn test_tilde_expands_to_absolute() { // Rather than mocking HOME (which dirs crate doesn't reliably use), diff --git a/apps/native/src/components/widget/promptinput/prompt-input.tsx b/apps/native/src/components/widget/promptinput/prompt-input.tsx index cf575d6ba..08fb7fe33 100644 --- a/apps/native/src/components/widget/promptinput/prompt-input.tsx +++ b/apps/native/src/components/widget/promptinput/prompt-input.tsx @@ -61,8 +61,18 @@ export function PromptInput() { if (!cancelled) { setProviderErrors({ - evolve: getProviderConfigInvalidReason(evolveProvider, normalizedPrefs, cliStatus), - summary: getProviderConfigInvalidReason(summaryProvider, normalizedPrefs, cliStatus), + evolve: getProviderConfigInvalidReason( + evolveProvider, + normalizedPrefs, + cliStatus, + prefs?.evolveModel, + ), + summary: getProviderConfigInvalidReason( + summaryProvider, + normalizedPrefs, + cliStatus, + prefs?.summaryModel, + ), }); } } catch { diff --git a/apps/native/src/components/widget/settings/ai-models-tab.tsx b/apps/native/src/components/widget/settings/ai-models-tab.tsx index a6e0bd884..d5b8ec51f 100644 --- a/apps/native/src/components/widget/settings/ai-models-tab.tsx +++ b/apps/native/src/components/widget/settings/ai-models-tab.tsx @@ -47,7 +47,7 @@ const DEFAULT_EVOLVE_MODEL: Record = { openrouter: "anthropic/claude-sonnet-4", openai: "anthropic/claude-sonnet-4", ollama: "", - vllm: "gpt-oss-120b", + vllm: "", claude: "", codex: "", opencode: "", @@ -56,8 +56,8 @@ const DEFAULT_EVOLVE_MODEL: Record = { const DEFAULT_SUMMARY_MODEL: Record = { openrouter: "openai/gpt-4o-mini", openai: "openai/gpt-4o-mini", - ollama: "llama3.1", - vllm: "gpt-oss-120b", + ollama: "", + vllm: "", claude: "", codex: "", opencode: "", @@ -140,11 +140,13 @@ export function AiModelsTab({ evolveProviderField.state.value, providerPrefs, cliStatus, + evolveModelField.state.value, ); const summaryProviderError = getProviderConfigInvalidReason( summaryProviderField.state.value, providerPrefs, cliStatus, + summaryModelField.state.value, ); return ( @@ -228,9 +230,9 @@ export function AiModelsTab({ onBlur={evolveModelField.handleBlur} placeholder={ evolveProvider === "ollama" - ? "" + ? "Select an installed Ollama model" : evolveProvider === "vllm" - ? "gpt-oss-120b" + ? "Enter vLLM model name" : evolveProvider === "opencode" ? "Leave empty for CLI default" : "anthropic/claude-sonnet-4" @@ -316,9 +318,9 @@ export function AiModelsTab({ onBlur={summaryModelField.handleBlur} placeholder={ summaryProvider === "ollama" - ? "llama3.1" + ? "Select an installed Ollama model" : summaryProvider === "vllm" - ? "gpt-oss-120b" + ? "Enter vLLM model name" : summaryProvider === "opencode" ? "Leave empty for CLI default" : "openai/gpt-4o-mini" diff --git a/apps/native/src/lib/ai-provider-validation.test.ts b/apps/native/src/lib/ai-provider-validation.test.ts index 941ce9f2d..b5c125dd1 100644 --- a/apps/native/src/lib/ai-provider-validation.test.ts +++ b/apps/native/src/lib/ai-provider-validation.test.ts @@ -33,4 +33,21 @@ describe("getProviderConfigInvalidReason", () => { ), ).toBeNull(); }); + + it("requires explicit local model names", () => { + expect(getProviderConfigInvalidReason("ollama", EMPTY_PREFS, NO_CLI_TOOLS, "")).toBe( + "No model set", + ); + expect( + getProviderConfigInvalidReason("ollama", EMPTY_PREFS, NO_CLI_TOOLS, " local "), + ).toBeNull(); + expect( + getProviderConfigInvalidReason( + "vllm", + { ...EMPTY_PREFS, vllmApiBaseUrl: "http://localhost:8000" }, + NO_CLI_TOOLS, + " ", + ), + ).toBe("No model set"); + }); }); diff --git a/apps/native/src/lib/ai-provider-validation.ts b/apps/native/src/lib/ai-provider-validation.ts index 6499af37f..75b3fa44b 100644 --- a/apps/native/src/lib/ai-provider-validation.ts +++ b/apps/native/src/lib/ai-provider-validation.ts @@ -10,6 +10,7 @@ export function getProviderConfigInvalidReason( provider: string, prefs: Pick, cliStatus: CliToolsState | null | undefined, + model?: string | null, ): string | null { if (isCliProvider(provider) && cliStatus != null) { const key = provider as keyof CliToolsState; @@ -25,7 +26,14 @@ export function getProviderConfigInvalidReason( } if (provider === "vllm") { - return prefs.vllmApiBaseUrl?.trim() ? null : "No base URL set"; + if (!prefs.vllmApiBaseUrl?.trim()) { + return "No base URL set"; + } + return model?.trim() ? null : "No model set"; + } + + if (provider === "ollama") { + return model?.trim() ? null : "No model set"; } return null;