feat: add direct AI model providers

This commit is contained in:
lucaronin
2026-05-03 16:15:03 +02:00
parent 5b906cb3a4
commit aaf0336733
57 changed files with 3899 additions and 845 deletions

474
src-tauri/src/ai_models.rs Normal file
View File

@@ -0,0 +1,474 @@
use crate::ai_agents::AiAgentStreamEvent;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiModelProviderKind {
OpenAi,
Anthropic,
OpenAiCompatible,
Ollama,
LmStudio,
OpenRouter,
Gemini,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiModelApiKeyStorage {
None,
Env,
LocalFile,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AiModelCapabilities {
pub streaming: bool,
pub tools: bool,
pub vision: bool,
pub json_mode: bool,
pub reasoning: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AiModelDefinition {
pub id: String,
pub display_name: Option<String>,
pub context_window: Option<u32>,
pub max_output_tokens: Option<u32>,
pub capabilities: AiModelCapabilities,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AiModelProvider {
pub id: String,
pub name: String,
pub kind: AiModelProviderKind,
pub base_url: Option<String>,
pub api_key_storage: Option<AiModelApiKeyStorage>,
pub api_key_env_var: Option<String>,
pub headers: Option<BTreeMap<String, String>>,
pub models: Vec<AiModelDefinition>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AiModelStreamRequest {
pub provider: AiModelProvider,
pub model_id: String,
pub message: String,
pub system_prompt: Option<String>,
pub api_key_override: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AiModelProviderTestRequest {
pub provider: AiModelProvider,
pub model_id: String,
pub api_key_override: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
struct AiProviderSecrets {
provider_api_keys: BTreeMap<String, String>,
}
pub fn normalize_ai_model_providers(
providers: Option<Vec<AiModelProvider>>,
) -> Option<Vec<AiModelProvider>> {
let normalized = providers?
.into_iter()
.filter_map(normalize_ai_model_provider)
.collect::<Vec<_>>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn normalize_ai_model_provider(mut provider: AiModelProvider) -> Option<AiModelProvider> {
provider.id = provider.id.trim().to_ascii_lowercase();
provider.name = provider.name.trim().to_string();
provider.base_url = normalize_optional_string(provider.base_url);
provider.api_key_env_var = normalize_optional_string(provider.api_key_env_var);
provider.api_key_storage = normalize_api_key_storage(&provider);
provider.models = normalized_models(provider.models);
is_valid_provider(&provider).then_some(provider)
}
fn normalize_api_key_storage(provider: &AiModelProvider) -> Option<AiModelApiKeyStorage> {
match provider.api_key_storage {
Some(AiModelApiKeyStorage::LocalFile) => Some(AiModelApiKeyStorage::LocalFile),
Some(AiModelApiKeyStorage::Env) | None if provider.api_key_env_var.is_some() => {
Some(AiModelApiKeyStorage::Env)
}
_ => Some(AiModelApiKeyStorage::None),
}
}
fn normalized_models(models: Vec<AiModelDefinition>) -> Vec<AiModelDefinition> {
models
.into_iter()
.filter_map(|mut model| {
model.id = model.id.trim().to_string();
model.display_name = normalize_optional_string(model.display_name);
(!model.id.is_empty()).then_some(model)
})
.collect()
}
fn is_valid_provider(provider: &AiModelProvider) -> bool {
!provider.id.is_empty() && !provider.name.is_empty() && !provider.models.is_empty()
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|candidate| candidate.trim().to_string())
.filter(|candidate| !candidate.is_empty())
}
pub fn run_ai_model_stream<F>(request: AiModelStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
emit(AiAgentStreamEvent::Init {
session_id: format!("api-{}", uuid::Uuid::new_v4()),
});
let text = send_model_message(&request)?;
emit(AiAgentStreamEvent::TextDelta { text });
emit(AiAgentStreamEvent::Done);
Ok(String::new())
}
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
let request = AiModelStreamRequest {
provider: request.provider,
model_id: request.model_id,
message: "Reply with exactly OK.".into(),
system_prompt: Some(
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
.into(),
),
api_key_override: normalize_optional_string(request.api_key_override),
};
send_model_message(&request)
}
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
match request.provider.kind {
AiModelProviderKind::Anthropic => send_anthropic_message(request),
_ => send_openai_compatible_message(request),
}
}
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
let mut messages = Vec::new();
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
}
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
let payload = serde_json::json!({
"model": request.model_id,
"messages": messages,
"stream": false
});
let json = send_json_request(request, endpoint, payload)?;
extract_openai_text(&json)
}
fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, String> {
let endpoint = format!("{}/messages", normalized_base_url(request)?);
let mut payload = serde_json::json!({
"model": request.model_id,
"max_tokens": selected_max_tokens(request),
"messages": [{ "role": "user", "content": request.message }]
});
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
payload["system"] = serde_json::Value::String(system_prompt.to_string());
}
let json = send_json_request(request, endpoint, payload)?;
extract_anthropic_text(&json)
}
fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
request
.provider
.models
.iter()
.find(|model| model.id == request.model_id)
.and_then(|model| model.max_output_tokens)
.unwrap_or(4096)
}
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
let fallback = match request.provider.kind {
AiModelProviderKind::OpenAi => "https://api.openai.com/v1",
AiModelProviderKind::Anthropic => "https://api.anthropic.com/v1",
AiModelProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
AiModelProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai",
AiModelProviderKind::Ollama => "http://localhost:11434/v1",
AiModelProviderKind::LmStudio => "http://127.0.0.1:1234/v1",
AiModelProviderKind::OpenAiCompatible => "",
};
let base = request
.provider
.base_url
.as_deref()
.and_then(non_empty_str)
.unwrap_or(fallback)
.trim_end_matches('/');
if base.is_empty() {
return Err("Custom API providers need a base URL.".into());
}
Ok(base.to_string())
}
fn send_json_request(
request: &AiModelStreamRequest,
endpoint: String,
payload: serde_json::Value,
) -> Result<serde_json::Value, String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|error| format!("Failed to create HTTP client: {error}"))?;
let builder = client.post(endpoint).json(&payload);
let builder = apply_auth_headers(builder, request)?;
let builder = apply_provider_headers(builder, request);
let response = send_provider_request(builder)?;
let status = response.status();
let text = response
.text()
.map_err(|error| format!("Failed to read AI provider response: {error}"))?;
if !status.is_success() {
return Err(format!(
"AI provider returned {status}: {}",
truncate_error(&text)
));
}
serde_json::from_str(&text)
.map_err(|error| format!("Failed to parse AI provider response: {error}"))
}
fn apply_auth_headers(
builder: reqwest::blocking::RequestBuilder,
request: &AiModelStreamRequest,
) -> Result<reqwest::blocking::RequestBuilder, String> {
let Some(api_key) = api_key_from_provider(request)? else {
return Ok(builder);
};
Ok(match request.provider.kind {
AiModelProviderKind::Anthropic => builder.header("x-api-key", api_key),
_ => builder.bearer_auth(api_key),
})
}
fn apply_provider_headers(
mut builder: reqwest::blocking::RequestBuilder,
request: &AiModelStreamRequest,
) -> reqwest::blocking::RequestBuilder {
if matches!(request.provider.kind, AiModelProviderKind::Anthropic) {
builder = builder.header("anthropic-version", "2023-06-01");
}
for (key, value) in safe_custom_headers(request) {
builder = builder.header(key, value);
}
builder
}
fn safe_custom_headers(request: &AiModelStreamRequest) -> Vec<(&String, &String)> {
request
.provider
.headers
.as_ref()
.into_iter()
.flat_map(|headers| headers.iter())
.filter(|(key, value)| {
!key.eq_ignore_ascii_case("authorization") && non_empty_option(Some(value)).is_some()
})
.collect()
}
fn send_provider_request(
builder: reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::Response, String> {
builder
.send()
.map_err(|error| format!("AI provider request failed: {error}"))
}
pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
let provider_id = normalize_secret_provider_id(&provider_id)?;
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
return Err("API key cannot be empty.".into());
}
let path = secrets_path()?;
let mut secrets = read_secrets_at(&path)?;
secrets.provider_api_keys.insert(provider_id, api_key);
write_secrets_at(&path, &secrets)
}
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
let provider_id = normalize_secret_provider_id(&provider_id)?;
let path = secrets_path()?;
let mut secrets = read_secrets_at(&path)?;
secrets.provider_api_keys.remove(&provider_id);
write_secrets_at(&path, &secrets)
}
fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
let provider_id = provider_id.trim().to_ascii_lowercase();
if provider_id.is_empty() {
Err("Provider ID cannot be empty.".into())
} else {
Ok(provider_id)
}
}
fn secrets_path() -> Result<std::path::PathBuf, String> {
crate::settings::preferred_app_config_path("ai-provider-secrets.json")
}
fn read_secrets_at(path: &Path) -> Result<AiProviderSecrets, String> {
if !path.exists() {
return Ok(AiProviderSecrets::default());
}
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read AI provider secrets: {error}"))?;
serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse AI provider secrets: {error}"))
}
fn write_secrets_at(path: &Path, secrets: &AiProviderSecrets) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create AI provider secrets directory: {error}"))?;
}
let json = serde_json::to_string_pretty(secrets)
.map_err(|error| format!("Failed to serialize AI provider secrets: {error}"))?;
write_secret_file(path, json)
}
#[cfg(unix)]
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|error| format!("Failed to open AI provider secrets file: {error}"))?;
file.write_all(content.as_bytes())
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|error| format!("Failed to secure AI provider secrets file: {error}"))
}
#[cfg(not(unix))]
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
fs::write(path, content)
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))
}
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
let secrets = read_secrets_at(&secrets_path()?)?;
let api_key = secrets
.provider_api_keys
.get(&request.provider.id)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if api_key.is_none() {
return Err(format!(
"No local API key is saved for {}.",
request.provider.name
));
}
Ok(api_key)
}
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
let Some(name) = request
.provider
.api_key_env_var
.as_deref()
.and_then(non_empty_str)
else {
return Ok(None);
};
std::env::var(name)
.map(Some)
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
}
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
if let Some(api_key) = request
.api_key_override
.as_deref()
.and_then(non_empty_str)
.map(str::to_string)
{
return Ok(Some(api_key));
}
match request.provider.api_key_storage {
Some(AiModelApiKeyStorage::LocalFile) => api_key_from_local_file(request),
Some(AiModelApiKeyStorage::Env) => api_key_from_env(request),
_ => Ok(None),
}
}
fn extract_openai_text(json: &serde_json::Value) -> Result<String, String> {
json["choices"][0]["message"]["content"]
.as_str()
.map(str::to_string)
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| "AI provider response did not include assistant text.".to_string())
}
fn extract_anthropic_text(json: &serde_json::Value) -> Result<String, String> {
let text = json["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|block| block["text"].as_str())
.collect::<Vec<_>>()
.join("");
if text.trim().is_empty() {
Err("Anthropic response did not include assistant text.".into())
} else {
Ok(text)
}
}
fn non_empty_option(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn non_empty_str(value: &str) -> Option<&str> {
non_empty_option(Some(value))
}
fn truncate_error(value: &str) -> String {
const MAX_ERROR_LENGTH: usize = 600;
if value.len() <= MAX_ERROR_LENGTH {
return value.to_string();
}
format!("{}...", &value[..MAX_ERROR_LENGTH])
}

View File

@@ -1,5 +1,7 @@
#[cfg(desktop)]
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
#[cfg(desktop)]
use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest};
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
@@ -88,18 +90,45 @@ fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRe
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_ai_agent(
app_handle: tauri::AppHandle,
fn run_normalized_ai_agent_stream(
request: AiAgentStreamRequest,
emitter: StreamEmitter<crate::ai_agents::AiAgentStreamEvent>,
) -> Result<String, String> {
run_desktop_stream(
app_handle,
"ai-agent-stream",
normalize_agent_request(request),
crate::ai_agents::run_ai_agent_stream,
)
.await
crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter)
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
AiAgentStreamRequest,
"ai-agent-stream",
run_normalized_ai_agent_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_model,
AiModelStreamRequest,
"ai-model-stream",
crate::ai_models::run_ai_model_stream
);
#[cfg(desktop)]
#[tauri::command]
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
crate::ai_models::save_provider_api_key(provider_id, api_key)
}
#[cfg(desktop)]
#[tauri::command]
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
crate::ai_models::delete_provider_api_key(provider_id)
}
#[cfg(desktop)]
#[tauri::command]
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
crate::ai_models::test_ai_model_provider(request)
}
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
@@ -158,6 +187,38 @@ pub async fn stream_ai_agent(
Err("CLI AI agents are not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_model(
_app_handle: tauri::AppHandle,
_request: crate::ai_models::AiModelStreamRequest,
) -> Result<String, String> {
Err("Direct AI model chat is not available in this mobile build yet.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn save_ai_model_provider_api_key(
_provider_id: String,
_api_key: String,
) -> Result<(), String> {
Err("Local AI provider secret storage is only available in the desktop app.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
Err("Local AI provider secret storage is only available in the desktop app.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn test_ai_model_provider(
_request: crate::ai_models::AiModelProviderTestRequest,
) -> Result<String, String> {
Err("Direct AI model tests are not available in this mobile build yet.".into())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,4 +1,5 @@
pub mod ai_agents;
pub mod ai_models;
pub mod app_updater;
pub mod claude_cli;
mod cli_agent_runtime;
@@ -413,6 +414,10 @@ macro_rules! app_invoke_handler {
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_ai_agent,
commands::stream_ai_model,
commands::save_ai_model_provider_api_key,
commands::delete_ai_model_provider_api_key,
commands::test_ai_model_provider,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,

View File

@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
@@ -78,6 +80,8 @@ pub struct Settings {
pub note_width_mode: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub default_ai_target: Option<String>,
pub ai_model_providers: Option<Vec<AiModelProvider>>,
pub hide_gitignored_files: Option<bool>,
pub all_notes_show_pdfs: Option<bool>,
pub all_notes_show_images: Option<bool>,
@@ -179,6 +183,8 @@ fn normalize_settings(settings: Settings) -> Settings {
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
default_ai_target: normalize_optional_string(settings.default_ai_target),
ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers),
hide_gitignored_files: settings.hide_gitignored_files,
all_notes_show_pdfs: settings.all_notes_show_pdfs,
all_notes_show_images: settings.all_notes_show_images,
@@ -326,6 +332,8 @@ mod tests {
note_width_mode: Some("wide".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
default_ai_target: Some("agent:codex".to_string()),
ai_model_providers: None,
hide_gitignored_files: Some(false),
all_notes_show_pdfs: Some(true),
all_notes_show_images: Some(true),