feat: explicit provider field replaces URL sniffing
CI / frontend (push) Successful in 30s
CI / rust (push) Successful in 5m42s

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.
This commit is contained in:
itsamejms
2026-09-06 23:27:20 +01:00
parent 624d82931b
commit 1e7301647d
4 changed files with 45 additions and 16 deletions
+13 -5
View File
@@ -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. // consistent with the user's world bible. Shared path = every caller.
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; 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 call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else { } else {
call_openai(&client, &config, &messages, temperature, max_tokens).await call_openai(&client, &config, &messages, temperature, max_tokens).await
@@ -65,7 +65,7 @@ pub async fn generate_stream(
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// For now, we do a non-streaming call and emit the full response as one token // 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 // 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 call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else { } else {
call_openai(&client, &config, &messages, temperature, max_tokens).await call_openai(&client, &config, &messages, temperature, max_tokens).await
@@ -123,14 +123,22 @@ pub struct ConnectionTest {
} }
#[tauri::command] #[tauri::command]
pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result<ConnectionTest, String> { pub async fn test_connection(
let config = state.config.lock().map_err(|e| e.to_string())?.clone(); state: tauri::State<'_, AppState>,
config: Option<crate::llm::LlmConfig>,
) -> Result<ConnectionTest, String> {
// 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() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8)) .timeout(std::time::Duration::from_secs(8))
.build() .build()
.map_err(|e| e.to_string())?; .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 url = format!("{}/api/tags", config.api_url.trim_end_matches('/'));
let res = client.get(&url).send().await.map_err(|e| e.to_string())?; let res = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() { if !res.status().is_success() {
+18 -2
View File
@@ -25,6 +25,12 @@ pub struct AppState {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig { 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_url: String,
pub api_key: String, pub api_key: String,
pub model: String, pub model: String,
@@ -42,6 +48,7 @@ impl Default for LlmConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
// Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI // 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_url: "http://localhost:11434".to_string(),
api_key: String::new(), api_key: String::new(),
model: "llama3.2".to_string(), model: "llama3.2".to_string(),
@@ -100,9 +107,18 @@ pub struct OllamaChatResponse {
pub done: bool, 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") url.contains("localhost:11434") || url.contains("127.0.0.1:11434")
} }
+2 -1
View File
@@ -26,7 +26,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
// Test connection to the default Ollama URL // Test connection to the default Ollama URL
const result = await invoke<{ ok: boolean; models: string[]; error: string }>( const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
"test_connection", "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) { if (result.ok) {
setModels(result.models); setModels(result.models);
@@ -52,6 +52,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
// We'll just set the config and complete. // We'll just set the config and complete.
await invoke("set_llm_config", { await invoke("set_llm_config", {
config: { config: {
provider: "ollama",
api_url: apiUrl, api_url: apiUrl,
api_key: apiKey, api_key: apiKey,
model: model, model: model,
+12 -8
View File
@@ -4,6 +4,8 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
interface LlmConfig { interface LlmConfig {
/** "ollama" | "openai" — empty = let the backend sniff the URL. */
provider?: string;
api_url: string; api_url: string;
api_key: string; api_key: string;
model: string; model: string;
@@ -174,16 +176,17 @@ export function SettingsPanel() {
setImgTesting(false); setImgTesting(false);
} }
// ponytail: provider presets fill in the API URL pattern + default model. // ponytail: provider presets fill in the API URL pattern + default model
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [ // + the API dialect (provider), so a custom-port Ollama isn't sniffed wrong.
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" }, const PRESETS: { label: string; url: string; model: string; provider: string; key?: boolean }[] = [
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" }, { label: "Ollama", url: "http://localhost:11434", model: "llama3.2", provider: "ollama" },
{ label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true }, { label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model", provider: "openai" },
{ label: "Custom", url: "", model: "" }, { 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; 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); setConn(null);
} }
@@ -192,6 +195,7 @@ export function SettingsPanel() {
// backend re-default on save — but we can't send a partial, so mirror the // backend re-default on save — but we can't send a partial, so mirror the
// known defaults here and toast. // known defaults here and toast.
const DEFAULTS: LlmConfig = { const DEFAULTS: LlmConfig = {
provider: "",
api_url: "http://localhost:11434", api_url: "http://localhost:11434",
api_key: "", api_key: "",
model: "llama3.2", model: "llama3.2",