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:
oxoxDev
2026-05-13 19:44:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent a1735b5589
commit 70083897a0
2 changed files with 355 additions and 4 deletions
+338
View File
@@ -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);
}
}
+17 -4
View File
@@ -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) {