From 70083897a08bea6d0154a4bfbe991be4d6ba96f5 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 14 May 2026 08:14:18 +0530 Subject: [PATCH] fix(integrations): fall back to default backend when api_url points at local AI (#51, #80, #7Z) (#1630) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Steven Enamakel --- src/api/config.rs | 338 +++++++++++++++++++++++++++ src/openhuman/integrations/client.rs | 21 +- 2 files changed, 355 insertions(+), 4 deletions(-) diff --git a/src/api/config.rs b/src/api/config.rs index d71f7521a..f7c7b7194 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -62,6 +62,142 @@ pub fn effective_api_url(api_url: &Option) -> String { default_api_base_url_for_env(app_env_from_env().as_deref()).to_string() } +/// Heuristic — does this URL look like a local-AI chat-completions endpoint +/// (Ollama, vLLM, LM Studio, OpenAI-compatible proxy on loopback) rather than +/// our hosted backend? +/// +/// Used by [`effective_integrations_api_url`] to avoid concatenating +/// backend-integration paths (e.g. `/agent-integrations/composio/toolkits`) +/// onto a user-set local-AI URL — see the Sentry cluster +/// `OPENHUMAN-TAURI-51 / -80 / -7Z` where Ollama users had every integration +/// request 404 because `config.api_url` was reused as both the chat base AND +/// the integrations base. +/// +/// Heuristic is intentionally tight: +/// - Path explicitly ends with the OpenAI-style chat-completions endpoint +/// (`/v1/chat/completions` or `/v1/completions`) — matches anywhere, OR +/// - Host is loopback (`127.0.0.1` / `localhost` / `::1` / `0.0.0.0`) or +/// a private RFC 1918 IPv4 range (`10.0.0.0/8`, `172.16.0.0/12`, +/// `192.168.0.0/16`) **AND** the URL carries an additional LLM signal: +/// either a known local-AI port (`11434` Ollama, `8000` vLLM, `8080` +/// common alt, `1234` LM Studio, `8888` Jupyter-style proxies) or a +/// path beginning with `/v1`. +/// +/// The combined loopback/private + LLM-signal requirement avoids +/// misclassifying ad-hoc mock backends bound on `127.0.0.1:` +/// with no path (the standard pattern used by our integration tests) as +/// local-AI while still catching every real-world Sentry case — those +/// always have either an LLM port or `/v1` in the URL. +/// +/// Both path arms in the chat-completions check use `ends_with` rather +/// than `contains` so a real backend URL whose path merely embeds the +/// segment as a substring (e.g. `/audit/v1/chat/completions-logs`) is +/// NOT misclassified. +/// +/// We deliberately do NOT match a bare `/v1` — that's a legitimate API +/// version suffix used by many self-hosted backends, and over-matching here +/// would silently route real backends to the default and break paying users. +pub fn looks_like_local_ai_endpoint(url: &str) -> bool { + let trimmed = url.trim(); + if trimmed.is_empty() { + return false; + } + let parsed = match url::Url::parse(trimmed) { + Ok(u) => u, + Err(_) => return false, + }; + let path = parsed.path(); + // Path-based match wins regardless of host so an OpenAI-style endpoint + // exposed on any host (LAN, tunnel, public IP) still classifies. + // `ends_with` (not `contains`) keeps a real backend whose path merely + // embeds the segment as a substring (e.g. `/audit/v1/chat/completions-logs`) + // from being misclassified. + if path.ends_with("/v1/chat/completions") || path.ends_with("/v1/completions") { + return true; + } + // Match by typed host so IPv4-mapped IPv6 (`::ffff:127.0.0.1`), + // the bare IPv6 loopback (`::1`), and IPv4 loopback all classify + // correctly regardless of how url::Url renders them via `host_str()`. + let host_is_local = match parsed.host() { + Some(url::Host::Ipv4(addr)) => { + addr.is_loopback() || addr.is_unspecified() || addr.is_private() + } + Some(url::Host::Ipv6(addr)) => addr.is_loopback() || addr.is_unspecified(), + Some(url::Host::Domain(name)) => { + let host = name.to_ascii_lowercase(); + host == "localhost" || host.ends_with(".localhost") + } + None => false, + }; + if !host_is_local { + return false; + } + // Loopback / private host alone is not enough — many tests bind + // mock backends on `127.0.0.1:` with no path, + // and we must not misclassify those as local-AI. Require an + // additional LLM signal: a known local-AI port or a `/v1` path. + const LOCAL_AI_PORTS: &[u16] = &[11434, 8000, 8080, 1234, 8888]; + let port_signals_llm = parsed + .port() + .map(|p| LOCAL_AI_PORTS.contains(&p)) + .unwrap_or(false); + let path_signals_llm = path.starts_with("/v1/") || path == "/v1"; + port_signals_llm || path_signals_llm +} + +/// Resolves the API base URL to use for **backend-proxied integrations** +/// (composio, channels, teams, etc.). +/// +/// Same resolution chain as [`effective_api_url`] EXCEPT the user override +/// is skipped when it [`looks_like_local_ai_endpoint`]. In that case we +/// fall through to env / default backend so integration requests still +/// hit the hosted API instead of being concatenated onto the user's +/// local Ollama/vLLM endpoint (which only knows about chat completions +/// and 404s every other path — see the Sentry cluster +/// `OPENHUMAN-TAURI-51 / -80 / -7Z`). +/// +/// Logs a one-shot `warn!` the first time the fallback fires so users +/// can see the diagnostic in their core sidecar logs. +/// +// TODO(#1663): rename to `effective_backend_api_url` and migrate the +// remaining `effective_api_url` callers across non-integrations domains +// (billing, team, referral, webhooks, credentials, channels, voice, +// socket, app_state, core/jsonrpc) per graycyrus review of PR #1630. +// Today they silently leak the user's local-AI endpoint as the base for +// every hosted-backend call — same bug shape, different surface. +pub fn effective_integrations_api_url(api_url: &Option) -> String { + if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if looks_like_local_ai_endpoint(u) { + warn_integrations_url_fallback_once(u); + // Fall through to env / default — do NOT use the user override. + } else { + return normalize_api_base_url(u); + } + } + if let Some(env_url) = api_base_from_env() { + return env_url; + } + default_api_base_url_for_env(app_env_from_env().as_deref()).to_string() +} + +/// Emit a single `warn!` **once per process lifetime** the first time +/// [`effective_integrations_api_url`] falls back away from a user-set +/// local-AI URL. Subsequent calls — including calls with a *different* +/// local-AI URL — are silently suppressed via `std::sync::Once` so we +/// don't spam logs on every integration request. +fn warn_integrations_url_fallback_once(local_url: &str) { + use std::sync::Once; + static WARNED: Once = Once::new(); + WARNED.call_once(|| { + tracing::warn!( + local_url = %local_url, + "[api/config] config.api_url looks like a local-AI endpoint; \ + integrations base will fall back to env/default backend so \ + /agent-integrations/* requests don't 404 against your local LLM" + ); + }); +} + /// Trim and strip trailing slashes so paths join consistently. pub fn normalize_api_base_url(url: &str) -> String { url.trim().trim_end_matches('/').to_string() @@ -337,4 +473,206 @@ mod tests { } assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai")); } + + // ── looks_like_local_ai_endpoint ─────────────────────────────────── + + #[test] + fn looks_like_local_ai_matches_loopback_hosts() { + // Ollama default + assert!(looks_like_local_ai_endpoint("http://127.0.0.1:11434/v1")); + // vLLM default + assert!(looks_like_local_ai_endpoint( + "http://127.0.0.1:8080/v1/chat/completions" + )); + // localhost variant + assert!(looks_like_local_ai_endpoint("http://localhost:11434/v1")); + // IPv6 loopback + assert!(looks_like_local_ai_endpoint("http://[::1]:11434")); + // Any-host bind, occasionally used by self-hosted dev rigs + assert!(looks_like_local_ai_endpoint("http://0.0.0.0:11434/v1")); + } + + #[test] + fn looks_like_local_ai_matches_chat_completions_path_on_non_loopback() { + // Some self-hosted setups expose the OpenAI-compatible endpoint on + // a non-loopback, non-private host (dev VM with a public IP, tunnel, + // mDNS .local name). The chat-completions path is still a strong + // tell that it's not our backend. + assert!(looks_like_local_ai_endpoint( + "http://203.0.113.5:8080/v1/chat/completions" + )); + assert!(looks_like_local_ai_endpoint( + "https://my-ollama.example/v1/completions" + )); + } + + #[test] + fn looks_like_local_ai_rejects_bare_loopback_with_random_port() { + // Integration tests (e.g. `composio/ops_tests.rs`) bind mock + // backends on `127.0.0.1:0` and let the kernel pick an ephemeral + // port (~32768-60999), with no path. Loopback alone is *not* a + // local-AI signal — we must not misclassify these as local-AI or + // every integration test that goes through `build_client` will + // see its request silently rerouted to the production backend. + assert!(!looks_like_local_ai_endpoint("http://127.0.0.1:54321")); + assert!(!looks_like_local_ai_endpoint("http://127.0.0.1:42000/")); + assert!(!looks_like_local_ai_endpoint("http://localhost:33333")); + assert!(!looks_like_local_ai_endpoint("http://[::1]:51234")); + } + + #[test] + fn looks_like_local_ai_matches_private_lan_hosts() { + // LAN-hosted Ollama / vLLM on RFC 1918 ranges — covered by the + // private-IP arm so users with `http://192.168.x.x:11434/v1` + // configurations don't see integration requests routed at the + // local LLM and 404. + assert!(looks_like_local_ai_endpoint( + "http://192.168.1.100:11434/v1" + )); + assert!(looks_like_local_ai_endpoint("http://10.0.0.5:8080/v1")); + assert!(looks_like_local_ai_endpoint("http://172.16.0.42:8000")); + } + + #[test] + fn looks_like_local_ai_rejects_real_backends() { + assert!(!looks_like_local_ai_endpoint("https://api.tinyhumans.ai")); + assert!(!looks_like_local_ai_endpoint( + "https://staging-api.tinyhumans.ai" + )); + // OpenAI public API — uses /v1 as a version prefix but no + // chat-completions path on its own; we must NOT misclassify it. + assert!(!looks_like_local_ai_endpoint("https://api.openai.com/v1")); + // Custom self-hosted backend exposing a bare /v1 prefix — still + // a real backend, must not be misclassified. + assert!(!looks_like_local_ai_endpoint( + "https://my-backend.example/v1" + )); + } + + #[test] + fn looks_like_local_ai_rejects_substring_path_false_positives() { + // graycyrus review of #1630: an earlier version used + // `path.contains("/v1/chat/completions")` which would misclassify + // any real backend whose path merely embedded that substring — + // e.g. an audit-log endpoint suffixed with `-logs`. Both arms now + // use `ends_with`, so these URLs must classify as NON-local. + assert!(!looks_like_local_ai_endpoint( + "https://real-backend.example/audit/v1/chat/completions-logs" + )); + assert!(!looks_like_local_ai_endpoint( + "https://real-backend.example/v1/chat/completions/history" + )); + assert!(!looks_like_local_ai_endpoint( + "https://real-backend.example/v1/completions-archive" + )); + } + + #[test] + fn looks_like_local_ai_handles_garbage_input() { + assert!(!looks_like_local_ai_endpoint("")); + assert!(!looks_like_local_ai_endpoint(" ")); + assert!(!looks_like_local_ai_endpoint("not a url")); + // Relative paths fail url::Url::parse — must not panic. + assert!(!looks_like_local_ai_endpoint("/v1/chat/completions")); + } + + // ── effective_integrations_api_url ───────────────────────────────── + + #[test] + fn integrations_url_falls_back_to_default_when_override_is_local_ai() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + // Clear env so we deterministically hit the default branch. + let prev_backend = std::env::var("BACKEND_URL").ok(); + let prev_vite_backend = std::env::var("VITE_BACKEND_URL").ok(); + let prev_app_env = std::env::var(APP_ENV_VAR).ok(); + let prev_vite_app_env = std::env::var(VITE_APP_ENV_VAR).ok(); + std::env::remove_var("BACKEND_URL"); + std::env::remove_var("VITE_BACKEND_URL"); + std::env::remove_var(APP_ENV_VAR); + std::env::remove_var(VITE_APP_ENV_VAR); + + let result = effective_integrations_api_url(&Some("http://127.0.0.1:11434/v1".to_string())); + + // Restore env before asserting so a failing assert doesn't leak. + match prev_backend { + Some(v) => std::env::set_var("BACKEND_URL", v), + None => std::env::remove_var("BACKEND_URL"), + } + match prev_vite_backend { + Some(v) => std::env::set_var("VITE_BACKEND_URL", v), + None => std::env::remove_var("VITE_BACKEND_URL"), + } + match prev_app_env { + Some(v) => std::env::set_var(APP_ENV_VAR, v), + None => std::env::remove_var(APP_ENV_VAR), + } + match prev_vite_app_env { + Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v), + None => std::env::remove_var(VITE_APP_ENV_VAR), + } + + assert_eq!(result, DEFAULT_API_BASE_URL); + } + + #[test] + fn integrations_url_falls_back_to_env_when_override_is_local_ai() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + let prev_backend = std::env::var("BACKEND_URL").ok(); + std::env::set_var("BACKEND_URL", "https://staging-api.tinyhumans.ai/"); + + let result = effective_integrations_api_url(&Some( + "http://127.0.0.1:8080/v1/chat/completions".to_string(), + )); + + match prev_backend { + Some(v) => std::env::set_var("BACKEND_URL", v), + None => std::env::remove_var("BACKEND_URL"), + } + + assert_eq!(result, "https://staging-api.tinyhumans.ai"); + } + + #[test] + fn integrations_url_keeps_real_backend_override() { + // User explicitly set a real backend host — must be respected. + let result = + effective_integrations_api_url(&Some("https://staging-api.tinyhumans.ai/".to_string())); + assert_eq!(result, "https://staging-api.tinyhumans.ai"); + } + + #[test] + fn integrations_url_matches_effective_api_url_without_override() { + let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap(); + // No override, no env → both helpers must agree. + let prev_backend = std::env::var("BACKEND_URL").ok(); + let prev_vite_backend = std::env::var("VITE_BACKEND_URL").ok(); + let prev_app_env = std::env::var(APP_ENV_VAR).ok(); + let prev_vite_app_env = std::env::var(VITE_APP_ENV_VAR).ok(); + std::env::remove_var("BACKEND_URL"); + std::env::remove_var("VITE_BACKEND_URL"); + std::env::remove_var(APP_ENV_VAR); + std::env::remove_var(VITE_APP_ENV_VAR); + + let integrations = effective_integrations_api_url(&None); + let api = effective_api_url(&None); + + match prev_backend { + Some(v) => std::env::set_var("BACKEND_URL", v), + None => std::env::remove_var("BACKEND_URL"), + } + match prev_vite_backend { + Some(v) => std::env::set_var("VITE_BACKEND_URL", v), + None => std::env::remove_var("VITE_BACKEND_URL"), + } + match prev_app_env { + Some(v) => std::env::set_var(APP_ENV_VAR, v), + None => std::env::remove_var(APP_ENV_VAR), + } + match prev_vite_app_env { + Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v), + None => std::env::remove_var(VITE_APP_ENV_VAR), + } + + assert_eq!(integrations, api); + } } diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index b60bcf1a9..c97e0cd23 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -270,9 +270,14 @@ impl IntegrationClient { /// /// Both the backend URL and the auth token come from **core defaults**: /// -/// - backend URL → [`crate::api::config::effective_api_url`] applied to -/// `config.api_url` (which itself falls back to the `BACKEND_URL` / -/// `VITE_BACKEND_URL` env vars and finally the hosted default). +/// - backend URL → [`crate::api::config::effective_integrations_api_url`] +/// applied to `config.api_url`. Unlike the plain +/// [`crate::api::config::effective_api_url`] resolver (which honours a +/// user-set local-AI endpoint so chat completions still work), the +/// integrations resolver detects local-AI URLs and falls back to the +/// `BACKEND_URL` / `VITE_BACKEND_URL` env vars (and finally the hosted +/// default) so backend-integration paths don't get concatenated onto a +/// local Ollama/vLLM endpoint and 404. /// - auth token → [`crate::api::jwt::get_session_token`], i.e. the /// app-session JWT written by `auth_store_session` — the same token /// that billing, team, webhooks, referral, memory, etc. all use. @@ -281,7 +286,15 @@ impl IntegrationClient { /// callers that need a kill switch (e.g. twilio, google_places, /// parallel) gate tool registration at their own level. pub fn build_client(config: &crate::openhuman::config::Config) -> Option> { - let backend_url = crate::api::config::effective_api_url(&config.api_url); + // Use the integrations-specific resolver: when `config.api_url` is set + // to a local-AI endpoint (Ollama, vLLM, …), it would still be perfect + // for `/v1/chat/completions`, but reusing it as the base for backend + // integration paths produces URLs like + // http://127.0.0.1:11434/v1/agent-integrations/composio/toolkits + // which 404 against the local LLM and flooded Sentry + // (OPENHUMAN-TAURI-51 / -80 / -7Z). The helper falls through to env / + // default backend in that case so integrations actually work. + let backend_url = crate::api::config::effective_integrations_api_url(&config.api_url); // Primary: app-session JWT from the auth profile store. let session_token = match crate::api::jwt::get_session_token(config) {