Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions apps/native/src-tauri/src/ai/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ pub trait ChatCompletionProvider: Send + Sync {
}
}

fn configured_model(store_model: Option<String>, env_var: &str) -> Option<String> {
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<String>,
env_var: &str,
) -> Result<String> {
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<R: Runtime>(
app_handle: Option<&AppHandle<R>>,
Expand All @@ -85,15 +107,12 @@ pub fn create_provider<R: Runtime>(
"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())
Expand All @@ -103,9 +122,7 @@ pub fn create_provider<R: Runtime>(
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())
Expand All @@ -124,8 +141,7 @@ pub fn create_provider<R: Runtime>(
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 {
Expand Down
36 changes: 25 additions & 11 deletions apps/native/src-tauri/src/evolve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, env_var: &str) -> Option<String> {
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<String>,
env_var: &str,
) -> Result<String> {
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 {
Expand Down Expand Up @@ -361,9 +381,7 @@ pub async fn generate_evolution<R: Runtime>(

// Select provider implementation
let provider: Arc<dyn AiProvider> = 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()
Expand All @@ -380,15 +398,12 @@ pub async fn generate_evolution<R: Runtime>(
"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()
Expand All @@ -403,8 +418,7 @@ pub async fn generate_evolution<R: Runtime>(
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 {
Expand Down
21 changes: 20 additions & 1 deletion apps/native/src-tauri/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ pub fn normalize_dir_input(input: &str) -> Result<std::path::PathBuf, String> {
}
}

pub fn non_empty_trimmed_string(input: impl AsRef<str>) -> Option<String> {
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".
Expand All @@ -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() {
Expand All @@ -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),
Expand Down
14 changes: 12 additions & 2 deletions apps/native/src/components/widget/promptinput/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 9 additions & 7 deletions apps/native/src/components/widget/settings/ai-models-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const DEFAULT_EVOLVE_MODEL: Record<string, string> = {
openrouter: "anthropic/claude-sonnet-4",
openai: "anthropic/claude-sonnet-4",
ollama: "",
vllm: "gpt-oss-120b",
vllm: "",
claude: "",
codex: "",
opencode: "",
Expand All @@ -56,8 +56,8 @@ const DEFAULT_EVOLVE_MODEL: Record<string, string> = {
const DEFAULT_SUMMARY_MODEL: Record<string, string> = {
openrouter: "openai/gpt-4o-mini",
openai: "openai/gpt-4o-mini",
ollama: "llama3.1",
vllm: "gpt-oss-120b",
ollama: "",
vllm: "",
claude: "",
codex: "",
opencode: "",
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions apps/native/src/lib/ai-provider-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
10 changes: 9 additions & 1 deletion apps/native/src/lib/ai-provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function getProviderConfigInvalidReason(
provider: string,
prefs: Pick<DarwinPrefs, "openrouterApiKey" | "openaiApiKey" | "vllmApiBaseUrl">,
cliStatus: CliToolsState | null | undefined,
model?: string | null,
): string | null {
if (isCliProvider(provider) && cliStatus != null) {
const key = provider as keyof CliToolsState;
Expand All @@ -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;
Expand Down
Loading