From 4b82d51f6cfebb73a5cf70bd85eb255f6fc66bdc Mon Sep 17 00:00:00 2001 From: qyrhal Date: Wed, 3 Jun 2026 18:49:11 +0200 Subject: [PATCH] Added openai compatible v1 completions and ollama api key compatibility (#73) (#74) Added: - v1 completions (OpenAI compatible) and testing connection - Add API key handling for Ollama and v1 completions if required Co-authored-by: midhun kumar Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/74 --- src-tauri/src/ai.rs | 194 +++++++++++++++++++++++- src-tauri/src/commands.rs | 45 ++++-- src-tauri/src/types.rs | 9 ++ src/lib/api.ts | 8 +- src/lib/components/SettingsPanel.svelte | 86 ++++++++++- src/lib/types.ts | 3 + 6 files changed, 319 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs index 2f43d5c..d7897a3 100644 --- a/src-tauri/src/ai.rs +++ b/src-tauri/src/ai.rs @@ -21,6 +21,8 @@ pub fn ai_request( std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { + // Handle all API keys as optional; ollama and v1 completions doesnt always require it. + let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) }; let result = match provider.as_str() { "openai" => { stream_openai( @@ -40,7 +42,7 @@ pub fn ai_request( stream_openai( &app, &url, - None, + key_opt, &model, &system_prompt, &user_message, @@ -48,6 +50,24 @@ pub fn ai_request( ) .await } + "openai_compatible" => { + let url = base_url.as_deref().unwrap_or(""); + if url.is_empty() { + Err("No base URL configured for OpenAI Compatible provider".to_string()) + } else { + let url = format!("{}/v1/completions", url.trim_end_matches('/')); + stream_completions( + &app, + &url, + key_opt, + &model, + &system_prompt, + &user_message, + &request_id, + ) + .await + } + } _ => { stream_anthropic( &app, @@ -353,12 +373,21 @@ pub async fn test_connection( model: &str, base_url: Option<&str>, ) -> Result { + let key_opt = if api_key.is_empty() { None } else { Some(api_key) }; match provider { "openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await, "ollama" => { let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL); let url = format!("{}/v1/chat/completions", url.trim_end_matches('/')); - test_openai(&url, None, model).await + test_openai(&url, key_opt, model).await + } + "openai_compatible" => { + let url = base_url.unwrap_or(""); + if url.is_empty() { + return Err("No base URL configured for OpenAI Compatible provider".to_string()); + } + let url = format!("{}/v1/completions", url.trim_end_matches('/')); + test_completions(&url, key_opt, model).await } _ => test_anthropic(api_key, model).await, } @@ -397,6 +426,167 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result { } } +async fn stream_completions( + app: &AppHandle, + url: &str, + api_key: Option<&str>, + model: &str, + system_prompt: &str, + user_message: &str, + _request_id: &str, +) -> Result<(), String> { + let client = Client::new(); + + let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message); + + let body = json!({ + "model": model, + "prompt": prompt, + "max_tokens": 4096, + "stream": true, + "temperature": 0.7 + }); + + let mut req = client + .post(url) + .header("content-type", "application/json"); + + if let Some(key) = api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let response = req + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + return Err(format!("API error {}: {}", status, body_text)); + } + + // Parse SSE stream + use futures::StreamExt; + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete SSE events from buffer + while let Some(event_end) = buffer.find("\n\n") { + let event_str = buffer[..event_end].to_string(); + buffer = buffer[event_end + 2..].to_string(); + + for line in event_str.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + let _ = app.emit( + "ai-stream", + AiStreamEvent { + event_type: "done".to_string(), + text: None, + error: None, + }, + ); + return Ok(()); + } + + if let Ok(parsed) = serde_json::from_str::(data) { + // Completions streaming: choices[0].text + if let Some(text) = parsed["choices"][0]["text"].as_str() { + if !text.is_empty() { + let _ = app.emit( + "ai-stream", + AiStreamEvent { + event_type: "text".to_string(), + text: Some(text.to_string()), + error: None, + }, + ); + } + } + + // Check finish_reason + if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() { + if reason == "stop" || reason == "length" { + let _ = app.emit( + "ai-stream", + AiStreamEvent { + event_type: "done".to_string(), + text: None, + error: None, + }, + ); + return Ok(()); + } + } + + // Check for error in stream + if let Some(err) = parsed["error"]["message"].as_str() { + let _ = app.emit( + "ai-stream", + AiStreamEvent { + event_type: "error".to_string(), + text: None, + error: Some(err.to_string()), + }, + ); + return Err(err.to_string()); + } + } + } + } + } + } + + let _ = app.emit( + "ai-stream", + AiStreamEvent { + event_type: "done".to_string(), + text: None, + error: None, + }, + ); + + Ok(()) +} + +async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result { + let client = Client::new(); + + let body = json!({ + "model": model, + "prompt": "Hi", + "max_tokens": 10, + }); + + let mut req = client + .post(url) + .header("content-type", "application/json"); + + if let Some(key) = api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let response = req + .json(&body) + .send() + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + if response.status().is_success() { + Ok("Connection successful".to_string()) + } else { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + Err(format!("API error {}: {}", status, body_text)) + } +} + async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result { let client = Client::new(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index efc2396..ce60779 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1654,12 +1654,22 @@ pub fn set_ai_settings( model: String, writing_style: Option, base_url: Option, + ollama_api_key: Option, + openai_compatible_base_url: Option, + openai_compatible_api_key: Option, ) -> Result<(), String> { let mut config = state.config.lock().map_err(|e| e.to_string())?; let key = api_key.filter(|k| !k.is_empty()); match provider.as_deref() { Some("openai") => config.openai_api_key = key, - Some("ollama") => config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()), + Some("ollama") => { + config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()); + config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty()); + } + Some("openai_compatible") => { + config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty()); + config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty()); + } _ => config.ai_api_key = key, } config.ai_provider = provider; @@ -1678,17 +1688,18 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> { .ai_provider .clone() .unwrap_or_else(|| "anthropic".to_string()); - let key = if provider == "ollama" { - // Ollama doesn't require an API key - Some(String::new()) - } else if provider == "openai" { - config.openai_api_key.clone() - } else { - config.ai_api_key.clone() + let key = match provider.as_str() { + "ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()), + "openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()), + "openai" => config.openai_api_key.clone(), + _ => config.ai_api_key.clone(), } .ok_or("No API key configured")?; let model = config.ai_model.clone(); - let base_url = config.ollama_base_url.clone(); + let base_url = match provider.as_str() { + "openai_compatible" => config.openai_compatible_base_url.clone(), + _ => config.ollama_base_url.clone(), + }; (provider, key, model, base_url) }; @@ -1734,17 +1745,19 @@ pub fn ai_ask( .ai_provider .clone() .unwrap_or_else(|| "anthropic".to_string()); - let key = if provider == "ollama" { - Some(String::new()) - } else if provider == "openai" { - config.openai_api_key.clone() - } else { - config.ai_api_key.clone() + let key = match provider.as_str() { + "ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()), + "openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()), + "openai" => config.openai_api_key.clone(), + _ => config.ai_api_key.clone(), } .ok_or("No API key configured. Go to Settings > AI to set up your API key.")?; let model = config.ai_model.clone(); let style = config.ai_writing_style.clone(); - let base_url = config.ollama_base_url.clone(); + let base_url = match provider.as_str() { + "openai_compatible" => config.openai_compatible_base_url.clone(), + _ => config.ollama_base_url.clone(), + }; (provider, key, model, style, base_url) }; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0163cf2..d75332d 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -109,6 +109,12 @@ pub struct AppConfig { pub openai_api_key: Option, #[serde(default)] pub ollama_base_url: Option, + #[serde(default)] + pub ollama_api_key: Option, + #[serde(default)] + pub openai_compatible_base_url: Option, + #[serde(default)] + pub openai_compatible_api_key: Option, #[serde(default = "default_ai_model")] pub ai_model: String, #[serde(default)] @@ -181,6 +187,9 @@ impl Default for AppConfig { ai_api_key: None, openai_api_key: None, ollama_base_url: None, + ollama_api_key: None, + openai_compatible_base_url: None, + openai_compatible_api_key: None, ai_model: "claude-sonnet-4-6".to_string(), ai_writing_style: None, default_view_mode: false, diff --git a/src/lib/api.ts b/src/lib/api.ts index b27e0b8..20d73d8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -351,8 +351,14 @@ export async function setAiSettings( model: string, writingStyle: string | null, baseUrl: string | null = null, + ollamaApiKey: string | null = null, + openaiCompatibleBaseUrl: string | null = null, + openaiCompatibleApiKey: string | null = null, ): Promise { - return invoke("set_ai_settings", { provider, apiKey, model, writingStyle, baseUrl }); + return invoke("set_ai_settings", { + provider, apiKey, model, writingStyle, baseUrl, + ollamaApiKey, openaiCompatibleBaseUrl, openaiCompatibleApiKey, + }); } export async function testAiConnection(): Promise { diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 47f8460..4ec1bbe 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -219,14 +219,20 @@ let aiApiKey = $derived.by(() => { if (aiProvider === 'openai') return _openaiKey; if (aiProvider === 'ollama') return ''; + if (aiProvider === 'openai_compatible') return _openaiCompatibleKey; return _anthropicKey; }); let _anthropicKey = $state($appConfig?.ai_api_key ?? ''); let _openaiKey = $state($appConfig?.openai_api_key ?? ''); let _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434'); + let _ollamaApiKey = $state($appConfig?.ollama_api_key ?? ''); + let _openaiCompatibleBaseUrl = $state($appConfig?.openai_compatible_base_url ?? ''); + let _openaiCompatibleKey = $state($appConfig?.openai_compatible_api_key ?? ''); let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-6'); let aiWritingStyle = $state($appConfig?.ai_writing_style ?? ''); let aiShowKey = $state(false); + let aiShowCompatibleKey = $state(false); + let aiShowOllamaKey = $state(false); let aiTestLoading = $state(false); let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); @@ -245,7 +251,16 @@ async function saveAiSettings() { const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null; - await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || null, baseUrl); + await setAiSettings( + aiProvider, + aiApiKey || null, + aiModel, + aiWritingStyle || null, + baseUrl, + _ollamaApiKey || null, + _openaiCompatibleBaseUrl || null, + _openaiCompatibleKey || null, + ); if ($appConfig) { $appConfig = { ...$appConfig, @@ -253,6 +268,9 @@ ai_api_key: _anthropicKey || null, openai_api_key: _openaiKey || null, ollama_base_url: _ollamaBaseUrl || null, + ollama_api_key: _ollamaApiKey || null, + openai_compatible_base_url: _openaiCompatibleBaseUrl || null, + openai_compatible_api_key: _openaiCompatibleKey || null, ai_model: aiModel, ai_writing_style: aiWritingStyle || null, }; @@ -1037,16 +1055,17 @@

Provider

-
+
+
{#if aiProvider} - {#if aiProvider === 'ollama'} + {#if aiProvider === 'ollama' || aiProvider === 'openai_compatible'}

Your data stays on your device. No text is sent to any external server.

{:else}

Your selected text and note content will be sent to {aiProvider === 'openai' ? 'OpenAI' : 'Anthropic'} servers for processing.

@@ -1065,6 +1084,59 @@ />

Ollama server address. Install from ollama.com

+
+

API Key (optional)

+
+ { _ollamaApiKey = (e.target as HTMLInputElement).value; }} + onblur={saveAiSettings} + /> + +
+
+ {:else if aiProvider === 'openai_compatible'} +
+

Server URL

+ { _openaiCompatibleBaseUrl = (e.target as HTMLInputElement).value; }} + onblur={saveAiSettings} + /> +

Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, etc.). Uses the v1/completions endpoint.

+
+
+

API Key (optional)

+
+ { _openaiCompatibleKey = (e.target as HTMLInputElement).value; }} + onblur={saveAiSettings} + /> + +
+
{:else}

API Key

@@ -1095,16 +1167,16 @@

Model

- {#if aiProvider === 'ollama'} + {#if aiProvider === 'ollama' || aiProvider === 'openai_compatible'} { aiModel = (e.target as HTMLInputElement).value; }} onblur={saveAiSettings} /> -

Enter the model name as shown by ollama list

+

{aiProvider === 'ollama' ? 'Enter the model name as shown by ollama list' : 'Enter the model name as required by your server'}

{:else}
{#each aiModels as m} @@ -1136,7 +1208,7 @@

Connection

-