From 1e7301647ddae9a547b84a985739b6bf3d23e1ee Mon Sep 17 00:00:00 2001 From: itsamejms Date: Sun, 6 Sep 2026 23:27:20 +0100 Subject: [PATCH] feat: explicit provider field replaces URL sniffing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LlmConfig.provider ("ollama" | "openai", empty = sniff URL so legacy configs keep working). Settings presets set it — a custom-port Ollama no longer falls into the OpenAI branch and fails confusingly. Also: test_connection now accepts an optional config override — the wizard was passing one that Rust silently ignored, so it tested the saved config instead of the URL the user just typed. --- src-tauri/src/commands/llm_commands.rs | 18 +++++++++++++----- src-tauri/src/llm/mod.rs | 20 ++++++++++++++++++-- src/components/FirstRunWizard.tsx | 3 ++- src/components/SettingsPanel.tsx | 20 ++++++++++++-------- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/llm_commands.rs b/src-tauri/src/commands/llm_commands.rs index 337babc..3c8d7a9 100644 --- a/src-tauri/src/commands/llm_commands.rs +++ b/src-tauri/src/commands/llm_commands.rs @@ -37,7 +37,7 @@ async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) // consistent with the user's world bible. Shared path = every caller. let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; - if llm::is_ollama(&config.api_url) { + if llm::is_ollama(&config.provider, &config.api_url) { call_ollama(&client, &config, &messages, temperature, max_tokens).await } else { call_openai(&client, &config, &messages, temperature, max_tokens).await @@ -65,7 +65,7 @@ pub async fn generate_stream( tauri::async_runtime::spawn(async move { // For now, we do a non-streaming call and emit the full response as one token // Real SSE streaming from Ollama/OpenAI can be added later - let result = if llm::is_ollama(&config.api_url) { + let result = if llm::is_ollama(&config.provider, &config.api_url) { call_ollama(&client, &config, &messages, temperature, max_tokens).await } else { call_openai(&client, &config, &messages, temperature, max_tokens).await @@ -123,14 +123,22 @@ pub struct ConnectionTest { } #[tauri::command] -pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result { - let config = state.config.lock().map_err(|e| e.to_string())?.clone(); +pub async fn test_connection( + state: tauri::State<'_, AppState>, + config: Option, +) -> Result { + // Optional override: the first-run wizard tests a config it hasn't saved + // yet. None = use the persisted state (Settings panel saves first anyway). + let config = match config { + Some(c) => c, + None => state.config.lock().map_err(|e| e.to_string())?.clone(), + }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(8)) .build() .map_err(|e| e.to_string())?; - if llm::is_ollama(&config.api_url) { + if llm::is_ollama(&config.provider, &config.api_url) { let url = format!("{}/api/tags", config.api_url.trim_end_matches('/')); let res = client.get(&url).send().await.map_err(|e| e.to_string())?; if !res.status().is_success() { diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 8fffdef..f777059 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -25,6 +25,12 @@ pub struct AppState { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LlmConfig { + /// API dialect: "ollama" (native /api/*) or "openai" (/v1/*). + /// Empty = sniff the URL (legacy configs + defaults). Provider presets + /// set it explicitly — a custom-port Ollama would otherwise be sniffed + /// as OpenAI and fail confusingly. serde default: old stored configs. + #[serde(default)] + pub provider: String, pub api_url: String, pub api_key: String, pub model: String, @@ -42,6 +48,7 @@ impl Default for LlmConfig { fn default() -> Self { Self { // Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI + provider: String::new(), api_url: "http://localhost:11434".to_string(), api_key: String::new(), model: "llama3.2".to_string(), @@ -100,9 +107,18 @@ pub struct OllamaChatResponse { pub done: bool, } -// ─── Helper: detect if we're talking to Ollama ─────────────── +// ─── Helper: are we talking Ollama? ─────────────────────── -pub fn is_ollama(url: &str) -> bool { +/// True when we should speak Ollama's native /api/* protocol. An explicit +/// provider on the config wins; empty falls back to URL sniffing so legacy +/// and default configs keep working. +pub fn is_ollama(provider: &str, url: &str) -> bool { + if provider.eq_ignore_ascii_case("ollama") { + return true; + } + if provider.eq_ignore_ascii_case("openai") { + return false; + } url.contains("localhost:11434") || url.contains("127.0.0.1:11434") } diff --git a/src/components/FirstRunWizard.tsx b/src/components/FirstRunWizard.tsx index ee195be..ad54836 100644 --- a/src/components/FirstRunWizard.tsx +++ b/src/components/FirstRunWizard.tsx @@ -26,7 +26,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) { // Test connection to the default Ollama URL const result = await invoke<{ ok: boolean; models: string[]; error: string }>( "test_connection", - { config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } } + { config: { provider: "ollama", api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } } ); if (result.ok) { setModels(result.models); @@ -52,6 +52,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) { // We'll just set the config and complete. await invoke("set_llm_config", { config: { + provider: "ollama", api_url: apiUrl, api_key: apiKey, model: model, diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 84e1315..6c2c4d8 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -4,6 +4,8 @@ import { open } from "@tauri-apps/plugin-dialog"; import { useToast } from "./Toast"; interface LlmConfig { + /** "ollama" | "openai" — empty = let the backend sniff the URL. */ + provider?: string; api_url: string; api_key: string; model: string; @@ -174,16 +176,17 @@ export function SettingsPanel() { setImgTesting(false); } - // ponytail: provider presets fill in the API URL pattern + default model. - const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [ - { label: "Ollama", url: "http://localhost:11434", model: "llama3.2" }, - { label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" }, - { label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true }, - { label: "Custom", url: "", model: "" }, + // ponytail: provider presets fill in the API URL pattern + default model + // + the API dialect (provider), so a custom-port Ollama isn't sniffed wrong. + const PRESETS: { label: string; url: string; model: string; provider: string; key?: boolean }[] = [ + { label: "Ollama", url: "http://localhost:11434", model: "llama3.2", provider: "ollama" }, + { label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model", provider: "openai" }, + { label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", provider: "openai", key: true }, + { label: "Custom", url: "", model: "", provider: "" }, ]; - function applyPreset(p: { url: string; model: string }) { + function applyPreset(p: { url: string; model: string; provider: string }) { if (!config) return; - setConfig({ ...config, api_url: p.url, model: p.model }); + setConfig({ ...config, api_url: p.url, model: p.model, provider: p.provider }); setConn(null); } @@ -192,6 +195,7 @@ export function SettingsPanel() { // backend re-default on save — but we can't send a partial, so mirror the // known defaults here and toast. const DEFAULTS: LlmConfig = { + provider: "", api_url: "http://localhost:11434", api_key: "", model: "llama3.2",