From 6a28e75e949ca7bcadb5bced1143ce9d8823cea4 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 21 Apr 2026 23:15:09 -0700 Subject: [PATCH] feat(routing): pluggable local inference (MLX, llama.cpp, LM Studio) + openhuman:// deep-link + OpenSSL 3 PKCS12 fix (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(routing,local-ai): pluggable local inference + openhuman:// deep-link Salvaged from recovery commit cfb1fd9f (Apr 19) — originally auto-backed up by claude-mem recovery tool, not yet PR'd. ## Local AI routing becomes pluggable (factory.rs, health.rs) Previously the IntelligentRoutingProvider hard-wired Ollama as the only local inference backend. This commit lets operators point at any OpenAI-compatible local server (llama.cpp / llama-server, LM Studio, MLX's mlx_lm.server / mlx-omni-server, vLLM — anything exposing /v1/chat/completions + /v1/models). Config surface: - `OPENHUMAN_LOCAL_INFERENCE_URL` env var — full /v1 base URL. When set, health is probed via GET {base}/models (OpenAI-compat) instead of Ollama's /api/tags. - `LocalAiConfig.provider` accepts "llamacpp" or "llama-server" as aliases that default to http://127.0.0.1:8080/v1. - Default (no env, no override) stays Ollama — zero config change for existing users. Motivation: Ollama's embedded llama.cpp cannot yet load Gemma 4 E2B and other recent models; MLX is significantly faster on Apple Silicon. Operators should be able to pick the backend; end users see nothing. ## Deep-link scheme (Info.plist) Adds CFBundleURLTypes entry registering `openhuman://` for the macOS bundle so browser-issued deep links (e.g. openhuman://auth?token=...) launch the installed app. See .claude/rules/14-deep-link-platform-guide.md for the flow — requires a .app bundle (not `tauri dev`). ## OpenSSL 3.x PKCS12 fix (setup-dev-codesign.sh) Adds `-legacy` to the `openssl pkcs12 -export` call. Required on OpenSSL 3.x because the modern SHA-256 MAC / AES-256-CBC defaults are not yet supported by macOS `security` tool, which silently fails to import the cert. Notes: - Dropped the CLAUDE.md.new noise and the mic-description copy downgrade from the original recovery commit; kept only the load-bearing changes. - Cargo.lock files reset to upstream/main to avoid dependency drift — the new code does not introduce new deps. Co-Authored-By: WOZCODE * fix(routing,codesign): address CodeRabbit review on PR #750 - setup-dev-codesign.sh: probe for openssl `-legacy` support before using it so older OpenSSL/LibreSSL installs don't silently fail; drop the stderr suppression on pkcs12 so import errors surface. - routing/factory.rs: trim provider before alias matching, lower the local-inference diagnostic to `debug` and drop raw URLs from the log fields, and honor `OPENHUMAN_OLLAMA_BASE_URL` via the existing `ollama_base_url()` helper in the default Ollama branch. - local_ai/mod.rs: re-export `ollama_base_url` for the routing factory. * style(app/src-tauri): apply rustfmt to merged main `cargo fmt --check` in the merge workspace flagged two hunks inherited from upstream main: - app/src-tauri/src/lib.rs: move `mod notification_settings;` between the cfg(cef) imessage_scanner and slack_scanner declarations so the ordering matches rustfmt's reordering output. - app/src-tauri/src/webview_accounts/mod.rs: wrap the long `try_state::()` binding. No behavior change. * format --------- Co-authored-by: Jwalin Shah Co-authored-by: WOZCODE Co-authored-by: Steven Enamakel --- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Info.plist | 11 +++++ app/src-tauri/src/lib.rs | 2 +- app/src-tauri/src/webview_accounts/mod.rs | 3 +- app/src/pages/Conversations.tsx | 6 +-- scripts/setup-dev-codesign.sh | 13 +++++- src/openhuman/local_ai/mod.rs | 2 +- src/openhuman/routing/factory.rs | 52 ++++++++++++++++++++--- src/openhuman/routing/health.rs | 8 +++- 9 files changed, 81 insertions(+), 18 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 9be01b531..b7da58645 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.52.27" +version = "0.52.28" dependencies = [ "anyhow", "async-trait", diff --git a/app/src-tauri/Info.plist b/app/src-tauri/Info.plist index c59ccb91e..61ce33af5 100644 --- a/app/src-tauri/Info.plist +++ b/app/src-tauri/Info.plist @@ -6,5 +6,16 @@ OpenHuman uses the microphone for voice dictation and for voice/video calls and huddles inside embedded apps (Google Meet, Discord, Slack). NSCameraUsageDescription OpenHuman uses the camera for video calls and huddles inside embedded apps (Google Meet, Discord, Slack). + CFBundleURLTypes + + + CFBundleURLName + com.openhuman.app + CFBundleURLSchemes + + openhuman + + + diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 76d024285..840043b6b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,11 +3,11 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp mod core_process; mod core_update; -mod notification_settings; #[cfg(feature = "cef")] mod discord_scanner; #[cfg(feature = "cef")] mod imessage_scanner; +mod notification_settings; #[cfg(feature = "cef")] mod slack_scanner; #[cfg(feature = "cef")] diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index f970c7e00..4db68608b 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -267,7 +267,8 @@ fn forward_native_notification( payload: &tauri_runtime_cef::notification::NotificationPayload, ) { // Feature flag — bail early when the user hasn't opted in. - if let Some(settings) = app.try_state::() + if let Some(settings) = + app.try_state::() { if !settings.enabled() { log::debug!( diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 1d4b27285..af3177300 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -39,10 +39,7 @@ import { openhumanVoiceTts, } from '../utils/tauriCommands'; import { formatTimelineEntry } from '../utils/toolTimelineFormatting'; -import { - AgentMessageBubble, - BubbleMarkdown, -} from './conversations/components/AgentMessageBubble'; +import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble'; import { LimitPill } from './conversations/components/LimitPill'; import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; import { @@ -66,7 +63,6 @@ type ReplyMode = 'text' | 'voice'; const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320; const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3; - interface ConversationsProps { /** * `page` (default) renders the centered max-w-2xl card layout used as diff --git a/scripts/setup-dev-codesign.sh b/scripts/setup-dev-codesign.sh index 7ae525ac2..1841a4668 100755 --- a/scripts/setup-dev-codesign.sh +++ b/scripts/setup-dev-codesign.sh @@ -65,13 +65,22 @@ openssl req \ 2>/dev/null # ── Bundle to PKCS12 ───────────────────────────────────────────────────────── +# `-legacy` keeps PKCS12 MAC/encryption compatible with macOS `security` tool +# which does not yet support OpenSSL 3.x defaults (SHA256 MAC / AES-256-CBC). +# Older OpenSSL/LibreSSL (including the macOS-bundled LibreSSL) do not know +# about `-legacy`, so probe for support before adding it. +PKCS12_LEGACY_ARGS=() +if openssl pkcs12 -help 2>&1 | grep -q -- '-legacy'; then + PKCS12_LEGACY_ARGS=(-legacy) +fi + openssl pkcs12 \ -export \ + "${PKCS12_LEGACY_ARGS[@]}" \ -out "$P12" \ -inkey "$KEY" \ -in "$CERT" \ - -passout "pass:$P12_PASS" \ - 2>/dev/null + -passout "pass:$P12_PASS" # ── Import into login Keychain ─────────────────────────────────────────────── security import "$P12" \ diff --git a/src/openhuman/local_ai/mod.rs b/src/openhuman/local_ai/mod.rs index 17ea31ce7..a0fbfb854 100644 --- a/src/openhuman/local_ai/mod.rs +++ b/src/openhuman/local_ai/mod.rs @@ -15,7 +15,7 @@ pub mod sentiment; mod install; pub(crate) mod model_ids; mod ollama_api; -pub(crate) use ollama_api::OLLAMA_BASE_URL; +pub(crate) use ollama_api::{ollama_base_url, OLLAMA_BASE_URL}; mod parse; pub(crate) mod paths; mod service; diff --git a/src/openhuman/routing/factory.rs b/src/openhuman/routing/factory.rs index f75865432..ff8c40112 100644 --- a/src/openhuman/routing/factory.rs +++ b/src/openhuman/routing/factory.rs @@ -1,13 +1,18 @@ use std::sync::Arc; +use std::time::Duration; use crate::openhuman::config::LocalAiConfig; -use crate::openhuman::local_ai::OLLAMA_BASE_URL; +use crate::openhuman::local_ai::ollama_base_url; use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider}; use crate::openhuman::providers::Provider; use super::health::LocalHealthChecker; use super::provider::IntelligentRoutingProvider; +/// Cache TTL for the non-ollama local health probe. Mirrors the default used +/// by [`LocalHealthChecker::new`]. +const LOCAL_HEALTH_TTL: Duration = Duration::from_secs(30); + /// Construct an [`IntelligentRoutingProvider`] from a remote backend provider /// and the local AI configuration. /// @@ -22,16 +27,51 @@ pub fn new_provider( local_ai_config: &LocalAiConfig, remote_fallback_model: &str, ) -> IntelligentRoutingProvider { - let local_base = format!("{}/v1", OLLAMA_BASE_URL); + // Allow operators to point the local routing tier at an OpenAI-compatible + // server other than Ollama (e.g. llama-server for Gemma 4 E2B, which + // Ollama's embedded llama.cpp cannot load yet as of April 2026). + // + // `OPENHUMAN_LOCAL_INFERENCE_URL` — full `/v1` base URL of the local + // OpenAI-compat server. When set, health is probed via `GET {base}/models` + // instead of Ollama's `/api/tags`. + let override_base = std::env::var("OPENHUMAN_LOCAL_INFERENCE_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()); + + let provider_kind = local_ai_config.provider.trim().to_ascii_lowercase(); + let use_openai_compat_local = + override_base.is_some() || matches!(provider_kind.as_str(), "llamacpp" | "llama-server"); + + let (provider_label, local_base, health) = if use_openai_compat_local { + let base = override_base.unwrap_or_else(|| "http://127.0.0.1:8080/v1".to_string()); + let probe = format!("{base}/models"); + tracing::debug!( + provider = %provider_kind, + "[routing] local inference configured via OpenAI-compat (non-ollama)" + ); + ( + "llamacpp", + base, + Arc::new(LocalHealthChecker::with_probe_url(probe, LOCAL_HEALTH_TTL)), + ) + } else { + let ollama_base = ollama_base_url(); + let local_v1 = format!("{ollama_base}/v1"); + ( + "ollama", + local_v1, + Arc::new(LocalHealthChecker::new(&ollama_base)), + ) + }; + let local: Box = Box::new(OpenAiCompatibleProvider::new( - "ollama", + provider_label, &local_base, - None, // Ollama does not require authentication + None, // local servers do not require authentication AuthStyle::Bearer, )); - let health = Arc::new(LocalHealthChecker::new(OLLAMA_BASE_URL)); - IntelligentRoutingProvider::new( remote, local, diff --git a/src/openhuman/routing/health.rs b/src/openhuman/routing/health.rs index 0030cb004..0196036f3 100644 --- a/src/openhuman/routing/health.rs +++ b/src/openhuman/routing/health.rs @@ -45,6 +45,12 @@ impl LocalHealthChecker { /// Create a checker with a custom cache TTL (useful in tests). pub fn with_ttl(base_url: &str, ttl: Duration) -> Self { + Self::with_probe_url(format!("{base_url}/api/tags"), ttl) + } + + /// Create a checker with an explicit full probe URL (for non-ollama local + /// backends such as llama-server, whose health endpoint is `/v1/models`). + pub fn with_probe_url(probe_url: String, ttl: Duration) -> Self { let client = reqwest::Client::builder() .timeout(PROBE_TIMEOUT) .build() @@ -57,7 +63,7 @@ impl LocalHealthChecker { }); Self { client, - probe_url: format!("{base_url}/api/tags"), + probe_url, cache: Mutex::new(None), ttl, }