mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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) <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Opus 4.7
Steven Enamakel
parent
a1735b5589
commit
70083897a0
@@ -62,6 +62,142 @@ pub fn effective_api_url(api_url: &Option<String>) -> 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:<random port>`
|
||||
/// 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:<random ephemeral port>` 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>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<IntegrationClient>> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user