mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
1095 lines
42 KiB
Rust
1095 lines
42 KiB
Rust
//! # API URL resolution & classification
|
|
//!
|
|
//! This module is the **single source of truth** for every URL the app uses to
|
|
//! reach either:
|
|
//!
|
|
//! * the **hosted backend** (auth, billing, integrations, voice, sockets, …), or
|
|
//! * the **LLM inference endpoint** (OpenAI-compatible chat completions).
|
|
//!
|
|
//! ## Why two separate URL families?
|
|
//!
|
|
//! Users can point `config.api_url` at a local model runner (Ollama, vLLM,
|
|
//! LM Studio). Those servers only speak `/v1/chat/completions` and 404 on
|
|
//! every other path. Naïvely reusing a single base URL for both families
|
|
//! caused every `/auth/*`, `/agent-integrations/*`, and `/voice/*` request to
|
|
//! 404 against the local runner — see Sentry cluster `OPENHUMAN-TAURI-51/-80/-7Z`.
|
|
//!
|
|
//! The fix is the [`effective_backend_api_url`] / [`effective_inference_url`]
|
|
//! split:
|
|
//!
|
|
//! ```text
|
|
//! config.api_url
|
|
//! │
|
|
//! ┌──────────┴──────────┐
|
|
//! │ looks_like_local_ai │
|
|
//! └──────────┬──────────┘
|
|
//! yes │ no
|
|
//! ┌───────────────┼────────────────────┐
|
|
//! ▼ ▼ ▼
|
|
//! env / default backend calls OK inference calls OK
|
|
//! (backend only)
|
|
//! ```
|
|
//!
|
|
//! ## Resolution order (both families)
|
|
//!
|
|
//! 1. Non-empty `config.api_url` / `config.inference_url` (user override).
|
|
//! 2. `BACKEND_URL` / `VITE_BACKEND_URL` runtime env (each checked
|
|
//! independently so an empty primary does not shadow a valid secondary).
|
|
//! 3. Same keys baked in at compile time via `option_env!` (makes a
|
|
//! distributed binary resolve to the correct environment without a shell).
|
|
//! 4. Environment-aware default: `staging` env → [`DEFAULT_STAGING_API_BASE_URL`],
|
|
//! otherwise [`DEFAULT_API_BASE_URL`].
|
|
|
|
// ─── Public constants ────────────────────────────────────────────────────────
|
|
|
|
/// Production hosted-API root. Used as the final fallback for non-staging
|
|
/// builds when no override is configured.
|
|
pub const DEFAULT_API_BASE_URL: &str = "https://api.tinyhumans.ai";
|
|
|
|
/// Staging hosted-API root. Activated when `OPENHUMAN_APP_ENV=staging` (or
|
|
/// the Vite equivalent) is set at runtime or baked in at compile time.
|
|
pub const DEFAULT_STAGING_API_BASE_URL: &str = "https://staging-api.tinyhumans.ai";
|
|
|
|
/// Runtime env key used by the Tauri/core side to select the app environment.
|
|
pub const APP_ENV_VAR: &str = "OPENHUMAN_APP_ENV";
|
|
|
|
/// Runtime env key exposed to the Vite frontend bundle. Mirrors `APP_ENV_VAR`
|
|
/// so both the core sidecar and the renderer agree on the environment without
|
|
/// a separate IPC round-trip.
|
|
pub const VITE_APP_ENV_VAR: &str = "VITE_OPENHUMAN_APP_ENV";
|
|
|
|
/// The path the hosted backend appends to its root to expose the
|
|
/// OpenAI-compatible inference proxy. Joined onto [`effective_api_url`] when
|
|
/// the user has not configured a dedicated `inference_url`.
|
|
///
|
|
/// Having this as a named constant (rather than a string literal scattered
|
|
/// across call-sites) means a backend path rename shows up as a single diff.
|
|
pub const OPENHUMAN_INFERENCE_PATH: &str = "/openai/v1/chat/completions";
|
|
|
|
// ─── Known local-AI ports ────────────────────────────────────────────────────
|
|
|
|
/// Well-known ports used by local model runners.
|
|
///
|
|
/// Used by [`looks_like_local_ai_endpoint`] as a secondary signal when the
|
|
/// URL's host is loopback / private but the path alone is not conclusive
|
|
/// (e.g. `http://localhost:11434` — no path, but clearly Ollama).
|
|
///
|
|
/// | Port | Runner |
|
|
/// |-------|---------------|
|
|
/// | 11434 | Ollama |
|
|
/// | 8000 | vLLM |
|
|
/// | 8080 | common alt |
|
|
/// | 1234 | LM Studio |
|
|
/// | 8888 | Jupyter proxy |
|
|
const LOCAL_AI_PORTS: &[u16] = &[11434, 8000, 8080, 1234, 8888];
|
|
|
|
// ─── Effective URL resolvers ─────────────────────────────────────────────────
|
|
|
|
/// Resolve the URL for **LLM inference calls** (chat completions only).
|
|
///
|
|
/// # Resolution order
|
|
///
|
|
/// 1. `inference_url_override` — user explicitly pointed inference at a
|
|
/// custom OpenAI-compatible endpoint (e.g. `https://api.openai.com/v1/chat/completions`
|
|
/// or a local Ollama). Used as-is; no path stripping.
|
|
/// 2. [`effective_api_url`]`(api_url_override)` + [`OPENHUMAN_INFERENCE_PATH`] —
|
|
/// inference proxied through the hosted backend.
|
|
///
|
|
/// # Why the split matters
|
|
///
|
|
/// Without a dedicated `inference_url`, every inference call flows through the
|
|
/// hosted backend's OpenAI-compat proxy. When the user *does* set
|
|
/// `inference_url`, backend calls still go to [`effective_backend_api_url`] —
|
|
/// so `/auth/*`, `/voice/*`, and `/agent-integrations/*` never accidentally
|
|
/// hit `api.openai.com` or a local runner.
|
|
pub fn effective_inference_url(
|
|
api_url_override: &Option<String>,
|
|
inference_url_override: &Option<String>,
|
|
) -> String {
|
|
// Explicit inference override always wins — no normalization applied
|
|
// because the user may intentionally include a full path.
|
|
if let Some(u) = non_empty_str(inference_url_override) {
|
|
return u.to_string();
|
|
}
|
|
|
|
api_url(
|
|
&effective_api_url(api_url_override),
|
|
OPENHUMAN_INFERENCE_PATH,
|
|
)
|
|
}
|
|
|
|
/// Resolve the **chat/inference base URL** (used for inference routing only,
|
|
/// not for backend domain calls).
|
|
///
|
|
/// Prefer [`effective_backend_api_url`] for anything other than chat completions.
|
|
/// The two functions are intentionally separate — see the module-level doc.
|
|
pub fn effective_api_url(api_url: &Option<String>) -> String {
|
|
if let Some(u) = non_empty_str(api_url) {
|
|
return normalize_api_base_url(u);
|
|
}
|
|
|
|
api_base_from_env()
|
|
.unwrap_or_else(|| default_api_base_url_for_env(app_env_from_env().as_deref()).to_string())
|
|
}
|
|
|
|
/// Resolve the API base URL for **all hosted-backend calls**:
|
|
/// auth, billing, team, referral, webhooks, credentials, channels,
|
|
/// voice, sockets, app-state, integrations, core/jsonrpc, …
|
|
///
|
|
/// # Key difference from [`effective_api_url`]
|
|
///
|
|
/// The user override is **skipped** when it [`looks_like_local_ai_endpoint`]
|
|
/// **and** does not [`looks_like_openhuman_backend_endpoint`]. In that case
|
|
/// the function falls through to the env / default chain so backend requests
|
|
/// still reach the hosted API.
|
|
///
|
|
/// A one-shot `warn!` is emitted the first time the fallback fires so the
|
|
/// diagnostic is visible in sidecar logs without spamming on every request.
|
|
///
|
|
/// # Sentry context
|
|
///
|
|
/// `OPENHUMAN-TAURI-51 / -80 / -7Z` — Ollama users saw every integration
|
|
/// request 404 because `config.api_url` (set to the Ollama endpoint) was also
|
|
/// used as the integrations base.
|
|
pub fn effective_backend_api_url(api_url: &Option<String>) -> String {
|
|
if let Some(u) = non_empty_str(api_url) {
|
|
let is_local_ai = looks_like_local_ai_endpoint(u);
|
|
let is_openhuman = looks_like_openhuman_backend_endpoint(u);
|
|
|
|
tracing::debug!(
|
|
api_url = %redact_url_for_log(u),
|
|
is_local_ai,
|
|
is_openhuman,
|
|
"[api/config] evaluating backend api_url override"
|
|
);
|
|
|
|
// Let the override through only when it is NOT a local-AI endpoint,
|
|
// OR when it is one of our own hosted backends (user deliberately set
|
|
// `api_url` to `https://api.tinyhumans.ai/openai/v1/chat/completions`).
|
|
if !is_local_ai || is_openhuman {
|
|
let normalized = normalize_backend_api_base_url(u);
|
|
tracing::trace!(
|
|
api_url = %redact_url_for_log(u),
|
|
normalized_url = %redact_url_for_log(&normalized),
|
|
"[api/config] using configured backend api_url override"
|
|
);
|
|
return normalized;
|
|
}
|
|
|
|
tracing::debug!(
|
|
api_url = %redact_url_for_log(u),
|
|
"[api/config] override classified as local AI — falling back to backend default chain"
|
|
);
|
|
warn_backend_url_fallback_once(u);
|
|
}
|
|
|
|
// Env / compile-time / default fallback — strip any inference path that
|
|
// may have slipped through a misconfigured `BACKEND_URL` (Sentry
|
|
// `OPENHUMAN-TAURI-H6 / -HN`, issue #2075).
|
|
api_base_from_env()
|
|
.map(|u| normalize_backend_api_base_url(&u))
|
|
.unwrap_or_else(|| default_api_base_url_for_env(app_env_from_env().as_deref()).to_string())
|
|
}
|
|
|
|
// ─── URL classification ──────────────────────────────────────────────────────
|
|
|
|
/// Returns `true` when the URL appears to be a local / self-hosted model
|
|
/// runner rather than the hosted OpenHuman backend.
|
|
///
|
|
/// The heuristic is **intentionally tight** to avoid misclassifying:
|
|
/// * ad-hoc mock backends used in integration tests
|
|
/// (`http://127.0.0.1:<ephemeral-port>` with no path), and
|
|
/// * real custom backends that happen to include `/v1` as an API-version prefix.
|
|
///
|
|
/// # Classification logic
|
|
///
|
|
/// ```text
|
|
/// ┌─ path ends with /v1/chat/completions ─────────────────────────────► TRUE
|
|
/// │ or /v1/completions (any host)
|
|
/// │
|
|
/// └─ host is loopback / private IP / localhost
|
|
/// AND (port ∈ LOCAL_AI_PORTS OR path starts with /v1/) ──────────► TRUE
|
|
///
|
|
/// everything else ────────────────────────────────────────────────────► FALSE
|
|
/// ```
|
|
///
|
|
/// Both path checks use `ends_with` (not `contains`) so a real backend whose
|
|
/// path merely *embeds* the segment (e.g. `/audit/v1/chat/completions-logs`)
|
|
/// is not misclassified.
|
|
///
|
|
/// A bare `/v1` path (e.g. `https://api.openai.com/v1`) intentionally does
|
|
/// NOT match — it is a legitimate API-version suffix used by many real
|
|
/// backends, and over-matching here would silently reroute 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();
|
|
|
|
// ── Signal 1: chat-completions path (wins regardless of host) ──────────
|
|
// `ends_with` not `contains` — see function doc.
|
|
if path.ends_with("/v1/chat/completions") || path.ends_with("/v1/completions") {
|
|
return true;
|
|
}
|
|
|
|
// ── Signal 2: loopback / private host + secondary LLM signal ───────────
|
|
if !host_is_local(&parsed) {
|
|
return false;
|
|
}
|
|
|
|
// Loopback alone is not enough — integration-test mock servers bind on
|
|
// `127.0.0.1:<ephemeral>` with no path. Require at least one LLM signal.
|
|
let port_is_llm = parsed
|
|
.port()
|
|
.map(|p| LOCAL_AI_PORTS.contains(&p))
|
|
.unwrap_or(false);
|
|
|
|
// `/v1/` (with trailing slash) or exactly `/v1` — avoids matching a bare
|
|
// root `/` which is indistinguishable from any plain HTTP server.
|
|
let path_is_llm = path.starts_with("/v1/") || path == "/v1";
|
|
|
|
port_is_llm || path_is_llm
|
|
}
|
|
|
|
/// Returns `true` when the URL's host is one of the known OpenHuman backends.
|
|
///
|
|
/// Used in [`effective_backend_api_url`] to short-circuit the local-AI check:
|
|
/// a user who set `api_url` to `https://api.tinyhumans.ai/openai/v1/chat/completions`
|
|
/// must still reach the real backend (not fall back to the default chain).
|
|
fn looks_like_openhuman_backend_endpoint(url: &str) -> bool {
|
|
let trimmed = url.trim();
|
|
let redacted = redact_url_for_log(trimmed);
|
|
|
|
let parsed = match url::Url::parse(trimmed) {
|
|
Ok(p) => {
|
|
tracing::trace!(
|
|
api_url = %redacted,
|
|
"[api/config] parsed api_url for OpenHuman backend classification"
|
|
);
|
|
p
|
|
}
|
|
Err(e) => {
|
|
tracing::trace!(
|
|
api_url = %redacted,
|
|
error = %e,
|
|
"[api/config] api_url parse failed during OpenHuman backend classification"
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let Some(host) = parsed.host_str().map(str::to_ascii_lowercase) else {
|
|
tracing::trace!(
|
|
api_url = %redacted,
|
|
"[api/config] api_url has no host — not classified as OpenHuman backend"
|
|
);
|
|
return false;
|
|
};
|
|
|
|
let is_openhuman = matches!(
|
|
host.as_str(),
|
|
"api.tinyhumans.ai" | "staging-api.tinyhumans.ai"
|
|
);
|
|
|
|
tracing::debug!(
|
|
api_url = %redacted,
|
|
host = %host,
|
|
is_openhuman,
|
|
"[api/config] OpenHuman backend classification complete"
|
|
);
|
|
|
|
is_openhuman
|
|
}
|
|
|
|
// ─── URL normalization helpers ───────────────────────────────────────────────
|
|
|
|
/// Trim whitespace and strip trailing slashes so all base URLs are in
|
|
/// canonical form before being joined with a path.
|
|
///
|
|
/// This is deliberately a cheap string operation (no URL parsing) so it can
|
|
/// be called on potentially-invalid strings without panicking.
|
|
pub fn normalize_api_base_url(url: &str) -> String {
|
|
url.trim().trim_end_matches('/').to_string()
|
|
}
|
|
|
|
/// Like [`normalize_api_base_url`] but also **strips any inference-style path**
|
|
/// (e.g. `/openai/v1/chat/completions`) so the result is always a bare host
|
|
/// root suitable as a backend base.
|
|
///
|
|
/// # Why this exists
|
|
///
|
|
/// Users (and CI configs) sometimes set `BACKEND_URL` or `config.api_url` to
|
|
/// the full inference endpoint. Backend callers append domain-specific paths
|
|
/// (`/auth/me`, `/agent-integrations/…`) which then land on
|
|
/// `.../openai/v1/chat/completions/auth/me` — an obvious 404.
|
|
///
|
|
/// # Scheme-less fallback
|
|
///
|
|
/// `option_env!`-baked values occasionally omit the scheme
|
|
/// (e.g. `api.tinyhumans.ai/openai/v1/chat/completions`). We retry with an
|
|
/// `https://` prefix so the path can still be stripped before the value is
|
|
/// used as a base. Without this, a scheme-less inference path survived into
|
|
/// every backend call — Sentry `OPENHUMAN-TAURI-H6 / -HN`, issue #2075.
|
|
pub(crate) fn normalize_backend_api_base_url(url: &str) -> String {
|
|
let normalized = normalize_api_base_url(url);
|
|
if normalized.is_empty() {
|
|
return normalized;
|
|
}
|
|
|
|
let parsed =
|
|
url::Url::parse(&normalized).or_else(|_| url::Url::parse(&format!("https://{normalized}")));
|
|
|
|
let Ok(mut parsed) = parsed else {
|
|
// Unparseable even with the scheme prefix — return as-is; the caller
|
|
// will surface a network error rather than silently 404.
|
|
return normalized;
|
|
};
|
|
|
|
// Strip everything after the host (path, query, fragment).
|
|
if parsed.path() != "/" {
|
|
parsed.set_path("");
|
|
}
|
|
parsed.set_query(None);
|
|
parsed.set_fragment(None);
|
|
|
|
parsed.to_string().trim_end_matches('/').to_string()
|
|
}
|
|
|
|
/// Safely join an API base URL with an absolute path.
|
|
///
|
|
/// # Behaviour
|
|
///
|
|
/// | `base` | `path` | result |
|
|
/// |-------------------------------------------|---------------------------|------------------------------------------------------------------------|
|
|
/// | `https://api.tinyhumans.ai` | `/auth/me` | `https://api.tinyhumans.ai/auth/me` |
|
|
/// | `https://api.tinyhumans.ai/openai/v1/…` | `/agent-integrations/foo` | `https://api.tinyhumans.ai/agent-integrations/foo` ← path replaced |
|
|
/// | `https://api.tinyhumans.ai` | `""` | `https://api.tinyhumans.ai` |
|
|
/// | `not a url` | `/x` | `not a url/x` ← safe fallback concat |
|
|
///
|
|
/// Paths **must start with `/`**. Relative paths (no leading slash) are
|
|
/// resolved per RFC 3986 — the base's last segment is dropped — which is
|
|
/// almost never what an API client wants.
|
|
pub fn api_url(base: &str, path: &str) -> String {
|
|
let base = base.trim();
|
|
|
|
if path.is_empty() {
|
|
return normalize_api_base_url(base);
|
|
}
|
|
|
|
match url::Url::parse(base) {
|
|
Ok(parsed) => match parsed.join(path) {
|
|
Ok(joined) => joined.to_string().trim_end_matches('/').to_string(),
|
|
Err(_) => fallback_concat(base, path),
|
|
},
|
|
Err(_) => fallback_concat(base, path),
|
|
}
|
|
}
|
|
|
|
/// Last-resort URL join used when `url::Url::parse` rejects the base.
|
|
///
|
|
/// Guarantees a slash between `base` and `path` regardless of whether either
|
|
/// carries one, but does not otherwise validate the resulting string.
|
|
#[inline]
|
|
fn fallback_concat(base: &str, path: &str) -> String {
|
|
let base = base.trim_end_matches('/');
|
|
if path.starts_with('/') {
|
|
format!("{base}{path}")
|
|
} else {
|
|
format!("{base}/{path}")
|
|
}
|
|
}
|
|
|
|
// ─── Environment resolution ───────────────────────────────────────────────────
|
|
|
|
/// Resolve the hosted API base URL from the environment.
|
|
///
|
|
/// Checks `BACKEND_URL` then `VITE_BACKEND_URL` independently (runtime first,
|
|
/// then compile-time bakes). An empty string for the primary key does **not**
|
|
/// shadow a valid secondary key — this matters when a `.env` file sets
|
|
/// `BACKEND_URL=""` to disable the override while keeping `VITE_BACKEND_URL`
|
|
/// active for the renderer.
|
|
///
|
|
/// Returns `None` when neither key is set or both are empty.
|
|
pub fn api_base_from_env() -> Option<String> {
|
|
// 1. Runtime — each key checked independently.
|
|
for key in ["BACKEND_URL", "VITE_BACKEND_URL"] {
|
|
if let Ok(v) = std::env::var(key) {
|
|
let url = normalize_api_base_url(&v);
|
|
if !url.is_empty() {
|
|
return Some(url);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Compile-time fallback — baked by the CI pipeline into the binary.
|
|
// Allows a shipped DMG / installer to resolve the correct environment
|
|
// without any shell vars in the user's session.
|
|
for v in compile_time_api_base_env_values().into_iter().flatten() {
|
|
let url = normalize_api_base_url(v);
|
|
if !url.is_empty() {
|
|
return Some(url);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Resolve the app environment string (e.g. `"staging"`, `"production"`).
|
|
///
|
|
/// Resolution order mirrors [`api_base_from_env`]: runtime vars first, then
|
|
/// compile-time bakes, each key checked independently.
|
|
pub fn app_env_from_env() -> Option<String> {
|
|
for key in [APP_ENV_VAR, VITE_APP_ENV_VAR] {
|
|
if let Ok(v) = std::env::var(key) {
|
|
let s = v.trim().to_ascii_lowercase();
|
|
if !s.is_empty() {
|
|
return Some(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
for v in compile_time_app_env_values().into_iter().flatten() {
|
|
let s = v.trim().to_ascii_lowercase();
|
|
if !s.is_empty() {
|
|
return Some(s);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Return `true` when `app_env` equals `"staging"` (case-insensitive).
|
|
pub fn is_staging_app_env(app_env: Option<&str>) -> bool {
|
|
matches!(app_env.map(str::trim), Some(env) if env.eq_ignore_ascii_case("staging"))
|
|
}
|
|
|
|
/// Map an app environment string to its canonical API base URL constant.
|
|
pub fn default_api_base_url_for_env(app_env: Option<&str>) -> &'static str {
|
|
if is_staging_app_env(app_env) {
|
|
DEFAULT_STAGING_API_BASE_URL
|
|
} else {
|
|
DEFAULT_API_BASE_URL
|
|
}
|
|
}
|
|
|
|
// ─── Compile-time env accessors ───────────────────────────────────────────────
|
|
|
|
/// Values baked in by the build pipeline.
|
|
///
|
|
/// Stubbed to `[None, None]` in tests so that clearing runtime env vars
|
|
/// produces fully deterministic results regardless of what the CI baked in.
|
|
#[cfg(not(test))]
|
|
fn compile_time_api_base_env_values() -> [Option<&'static str>; 2] {
|
|
[option_env!("BACKEND_URL"), option_env!("VITE_BACKEND_URL")]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compile_time_api_base_env_values() -> [Option<&'static str>; 2] {
|
|
[None, None]
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
fn compile_time_app_env_values() -> [Option<&'static str>; 2] {
|
|
[
|
|
option_env!("OPENHUMAN_APP_ENV"),
|
|
option_env!("VITE_OPENHUMAN_APP_ENV"),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compile_time_app_env_values() -> [Option<&'static str>; 2] {
|
|
[None, None]
|
|
}
|
|
|
|
// ─── Logging helpers ─────────────────────────────────────────────────────────
|
|
|
|
/// Redact username and password from a URL before writing it to a log.
|
|
///
|
|
/// Falls back to a scheme-prefixed parse for bare-host strings like
|
|
/// `localhost:1234` so those are still sanitised rather than returned verbatim.
|
|
pub(crate) fn redact_url_for_log(raw: &str) -> String {
|
|
let trimmed = raw.trim();
|
|
|
|
let parsed =
|
|
url::Url::parse(trimmed).or_else(|_| url::Url::parse(&format!("http://{trimmed}")));
|
|
|
|
let Ok(mut parsed) = parsed else {
|
|
return trimmed.to_string();
|
|
};
|
|
|
|
if !parsed.username().is_empty() {
|
|
let _ = parsed.set_username("redacted");
|
|
}
|
|
if parsed.password().is_some() {
|
|
let _ = parsed.set_password(Some("redacted"));
|
|
}
|
|
|
|
parsed.to_string().trim_end_matches('/').to_string()
|
|
}
|
|
|
|
/// Emit a single `warn!` log the **first time** the backend URL falls back
|
|
/// from a user-set local-AI endpoint. Uses `std::sync::Once` to suppress
|
|
/// subsequent emissions so the log is not spammed on every backend request.
|
|
fn warn_backend_url_fallback_once(local_url: &str) {
|
|
use std::sync::Once;
|
|
static WARNED: Once = Once::new();
|
|
WARNED.call_once(|| {
|
|
tracing::warn!(
|
|
local_url = %redact_url_for_log(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"
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Private utilities ───────────────────────────────────────────────────────
|
|
|
|
/// Extract a trimmed, non-empty string reference from an `Option<String>`.
|
|
///
|
|
/// Centralises the `as_deref().map(str::trim).filter(|s| !s.is_empty())`
|
|
/// pattern that was repeated throughout the original code.
|
|
#[inline]
|
|
fn non_empty_str(s: &Option<String>) -> Option<&str> {
|
|
s.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// Returns `true` when the parsed URL's host is loopback, unspecified
|
|
/// (`0.0.0.0` / `[::]`), a private RFC 1918 IPv4 range, or `localhost`.
|
|
///
|
|
/// Using typed-host matching (via `url::Host` variants) rather than
|
|
/// `host_str()` string comparison ensures that IPv4-mapped IPv6 addresses
|
|
/// (`::ffff:127.0.0.1`), the bare IPv6 loopback (`::1`), and all three
|
|
/// IPv4 loopback forms classify correctly.
|
|
#[inline]
|
|
fn host_is_local(parsed: &url::Url) -> bool {
|
|
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 h = name.to_ascii_lowercase();
|
|
h == "localhost" || h.ends_with(".localhost")
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
|
|
|
use super::*;
|
|
|
|
// ── Test infrastructure ───────────────────────────────────────────────────
|
|
|
|
/// Global mutex that serialises all env-mutating tests.
|
|
/// `std::env` is process-global; without serialisation, parallel test
|
|
/// threads race on `set_var` / `remove_var` and produce flaky failures.
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> MutexGuard<'static, ()> {
|
|
match ENV_LOCK.get_or_init(Mutex::default).lock() {
|
|
Ok(g) => g,
|
|
Err(p) => p.into_inner(), // recover from a poisoned lock
|
|
}
|
|
}
|
|
|
|
/// RAII guard that captures the current values of the four backend env
|
|
/// vars, removes them, and restores them on drop — even if the test panics.
|
|
struct EnvSnapshot {
|
|
vars: [(&'static str, Option<String>); 4],
|
|
}
|
|
|
|
impl EnvSnapshot {
|
|
fn clear_backend_env() -> Self {
|
|
let keys = [
|
|
"BACKEND_URL",
|
|
"VITE_BACKEND_URL",
|
|
APP_ENV_VAR,
|
|
VITE_APP_ENV_VAR,
|
|
];
|
|
let vars = keys.map(|k| (k, std::env::var(k).ok()));
|
|
for (k, _) in &vars {
|
|
std::env::remove_var(k);
|
|
}
|
|
Self { vars }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvSnapshot {
|
|
fn drop(&mut self) {
|
|
for (key, value) in &self.vars {
|
|
match value {
|
|
Some(v) => std::env::set_var(key, v),
|
|
None => std::env::remove_var(key),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The URL that should be used as the backend base when no config override
|
|
/// is present and the runtime env has been cleared for the test.
|
|
fn fallback_backend_base_for_current_build() -> String {
|
|
api_base_from_env().unwrap_or_else(|| {
|
|
default_api_base_url_for_env(app_env_from_env().as_deref()).to_string()
|
|
})
|
|
}
|
|
|
|
// ── api_url ───────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn api_url_empty_path_returns_normalized_base() {
|
|
assert_eq!(
|
|
api_url("https://api.tinyhumans.ai", ""),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
api_url("https://api.tinyhumans.ai/", ""),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
api_url(" https://api.tinyhumans.ai/ ", ""),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_absolute_path_replaces_base_path() {
|
|
// Regression: a base with an inference path baked in must not corrupt
|
|
// /agent-integrations/* calls.
|
|
assert_eq!(
|
|
api_url(
|
|
"https://api.tinyhumans.ai/openai/v1/chat/completions",
|
|
"/agent-integrations/composio/toolkits",
|
|
),
|
|
"https://api.tinyhumans.ai/agent-integrations/composio/toolkits"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_clean_base_joins_cleanly() {
|
|
let expected = "https://api.tinyhumans.ai/agent-integrations/composio/toolkits";
|
|
assert_eq!(
|
|
api_url(
|
|
"https://api.tinyhumans.ai",
|
|
"/agent-integrations/composio/toolkits"
|
|
),
|
|
expected
|
|
);
|
|
assert_eq!(
|
|
api_url(
|
|
"https://api.tinyhumans.ai/",
|
|
"/agent-integrations/composio/toolkits"
|
|
),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_preserves_query_string_on_path() {
|
|
assert_eq!(
|
|
api_url(
|
|
"https://api.tinyhumans.ai",
|
|
"/agent-integrations/composio/tools?toolkits=gmail"
|
|
),
|
|
"https://api.tinyhumans.ai/agent-integrations/composio/tools?toolkits=gmail"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_unparseable_base_falls_back_to_concat() {
|
|
assert_eq!(api_url("not a url", "/x"), "not a url/x");
|
|
assert_eq!(api_url("not a url/", "/x"), "not a url/x");
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_with_lm_studio_base_joins_correctly() {
|
|
// LM Studio URL must not reach effective_backend_api_url in practice
|
|
// (it redirects), but api_url itself must not panic and the result
|
|
// must use the correct host root.
|
|
assert_eq!(
|
|
api_url("http://localhost:1234/v1", "/agent-integrations/foo"),
|
|
"http://localhost:1234/agent-integrations/foo"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_multiple_trailing_slashes_on_base_are_stripped() {
|
|
assert_eq!(
|
|
api_url("https://api.tinyhumans.ai///", "/v1/foo"),
|
|
"https://api.tinyhumans.ai/v1/foo"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_url_relative_path_without_leading_slash_does_not_panic() {
|
|
// Documented edge-case: relative paths are resolved RFC 3986-style
|
|
// (last base segment dropped). The exact result depends on base
|
|
// structure; we just pin the no-panic contract.
|
|
assert!(!api_url("https://api.tinyhumans.ai", "relative").is_empty());
|
|
}
|
|
|
|
// ── normalize_api_base_url ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn normalize_strips_trailing_slashes_and_whitespace() {
|
|
assert_eq!(
|
|
normalize_api_base_url("https://api.tinyhumans.ai/"),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
normalize_api_base_url("https://api.tinyhumans.ai///"),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
normalize_api_base_url(" https://api.tinyhumans.ai "),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
normalize_api_base_url(" https://api.tinyhumans.ai/ "),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_preserves_mid_path() {
|
|
assert_eq!(
|
|
normalize_api_base_url("https://api.tinyhumans.ai/v2"),
|
|
"https://api.tinyhumans.ai/v2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_empty_string_returns_empty() {
|
|
assert_eq!(normalize_api_base_url(""), "");
|
|
}
|
|
|
|
// ── normalize_backend_api_base_url ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn normalize_backend_strips_inference_path() {
|
|
assert_eq!(
|
|
normalize_backend_api_base_url("https://api.tinyhumans.ai/openai/v1/chat/completions"),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_backend_handles_schemeless_input() {
|
|
// Sentry OPENHUMAN-TAURI-H6 / issue #2075.
|
|
assert_eq!(
|
|
normalize_backend_api_base_url("api.tinyhumans.ai/openai/v1/chat/completions"),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_backend_passes_through_clean_root() {
|
|
assert_eq!(
|
|
normalize_backend_api_base_url("https://api.tinyhumans.ai/"),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_backend_empty_string_is_idempotent() {
|
|
assert_eq!(normalize_backend_api_base_url(""), "");
|
|
}
|
|
|
|
// ── app / api env resolution ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn staging_env_resolves_to_staging_url() {
|
|
assert_eq!(
|
|
default_api_base_url_for_env(Some("staging")),
|
|
DEFAULT_STAGING_API_BASE_URL
|
|
);
|
|
assert!(is_staging_app_env(Some("STAGING")));
|
|
}
|
|
|
|
#[test]
|
|
fn non_staging_env_resolves_to_production_url() {
|
|
assert_eq!(
|
|
default_api_base_url_for_env(Some("production")),
|
|
DEFAULT_API_BASE_URL
|
|
);
|
|
assert_eq!(default_api_base_url_for_env(None), DEFAULT_API_BASE_URL);
|
|
assert!(!is_staging_app_env(Some("development")));
|
|
}
|
|
|
|
#[test]
|
|
fn app_env_from_env_reads_runtime_var() {
|
|
let _guard = env_lock();
|
|
let prev = std::env::var(APP_ENV_VAR).ok();
|
|
std::env::set_var(APP_ENV_VAR, "staging");
|
|
let result = app_env_from_env();
|
|
match prev {
|
|
Some(v) => std::env::set_var(APP_ENV_VAR, v),
|
|
None => std::env::remove_var(APP_ENV_VAR),
|
|
}
|
|
assert_eq!(result.as_deref(), Some("staging"));
|
|
}
|
|
|
|
#[test]
|
|
fn app_env_empty_primary_falls_through_to_secondary() {
|
|
let _guard = env_lock();
|
|
let prev_p = std::env::var(APP_ENV_VAR).ok();
|
|
let prev_s = std::env::var(VITE_APP_ENV_VAR).ok();
|
|
std::env::set_var(APP_ENV_VAR, "");
|
|
std::env::set_var(VITE_APP_ENV_VAR, "staging");
|
|
let result = app_env_from_env();
|
|
match prev_p {
|
|
Some(v) => std::env::set_var(APP_ENV_VAR, v),
|
|
None => std::env::remove_var(APP_ENV_VAR),
|
|
}
|
|
match prev_s {
|
|
Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v),
|
|
None => std::env::remove_var(VITE_APP_ENV_VAR),
|
|
}
|
|
assert_eq!(result.as_deref(), Some("staging"));
|
|
}
|
|
|
|
#[test]
|
|
fn api_base_from_env_reads_runtime_var() {
|
|
let _guard = env_lock();
|
|
let prev = std::env::var("BACKEND_URL").ok();
|
|
std::env::set_var("BACKEND_URL", "https://staging-api.tinyhumans.ai/");
|
|
let result = api_base_from_env();
|
|
match prev {
|
|
Some(v) => std::env::set_var("BACKEND_URL", v),
|
|
None => std::env::remove_var("BACKEND_URL"),
|
|
}
|
|
assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai"));
|
|
}
|
|
|
|
#[test]
|
|
fn api_base_empty_primary_falls_through_to_secondary() {
|
|
let _guard = env_lock();
|
|
let prev_p = std::env::var("BACKEND_URL").ok();
|
|
let prev_s = std::env::var("VITE_BACKEND_URL").ok();
|
|
std::env::set_var("BACKEND_URL", "");
|
|
std::env::set_var("VITE_BACKEND_URL", "https://staging-api.tinyhumans.ai/");
|
|
let result = api_base_from_env();
|
|
match prev_p {
|
|
Some(v) => std::env::set_var("BACKEND_URL", v),
|
|
None => std::env::remove_var("BACKEND_URL"),
|
|
}
|
|
match prev_s {
|
|
Some(v) => std::env::set_var("VITE_BACKEND_URL", v),
|
|
None => std::env::remove_var("VITE_BACKEND_URL"),
|
|
}
|
|
assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai"));
|
|
}
|
|
|
|
// ── looks_like_local_ai_endpoint ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn local_ai_matches_loopback_hosts() {
|
|
assert!(looks_like_local_ai_endpoint("http://127.0.0.1:11434/v1"));
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"http://127.0.0.1:8080/v1/chat/completions"
|
|
));
|
|
assert!(looks_like_local_ai_endpoint("http://localhost:11434/v1"));
|
|
assert!(looks_like_local_ai_endpoint("http://[::1]:11434"));
|
|
assert!(looks_like_local_ai_endpoint("http://0.0.0.0:11434/v1"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_ai_matches_chat_completions_path_on_any_host() {
|
|
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 local_ai_rejects_bare_loopback_with_random_port() {
|
|
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 local_ai_matches_private_lan_hosts() {
|
|
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 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 exposes /v1 as a version prefix — must NOT match.
|
|
assert!(!looks_like_local_ai_endpoint("https://api.openai.com/v1"));
|
|
assert!(!looks_like_local_ai_endpoint(
|
|
"https://my-backend.example/v1"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_ai_rejects_substring_path_false_positives() {
|
|
// Earlier version used `contains` — these are the regressions it caused.
|
|
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 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"));
|
|
assert!(!looks_like_local_ai_endpoint("/v1/chat/completions")); // relative — must not panic
|
|
}
|
|
|
|
#[test]
|
|
fn local_ai_matches_lm_studio_default_port() {
|
|
assert!(looks_like_local_ai_endpoint("http://localhost:1234"));
|
|
assert!(looks_like_local_ai_endpoint("http://127.0.0.1:1234"));
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"http://127.0.0.1:1234/v1/chat/completions"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_ai_matches_v1_subpath_on_loopback() {
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"http://localhost:11434/v1/models"
|
|
));
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"http://127.0.0.1:8080/v1/embeddings"
|
|
));
|
|
}
|
|
|
|
// ── openhuman_backend detection ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn openhuman_backend_detection_accepts_hosted_api_paths() {
|
|
assert!(looks_like_openhuman_backend_endpoint(
|
|
"https://api.tinyhumans.ai/openai/v1/chat/completions"
|
|
));
|
|
assert!(looks_like_openhuman_backend_endpoint(
|
|
"https://staging-api.tinyhumans.ai/openai/v1/chat/completions"
|
|
));
|
|
assert!(!looks_like_openhuman_backend_endpoint(
|
|
"https://openrouter.ai/api/v1/chat/completions"
|
|
));
|
|
assert!(!looks_like_openhuman_backend_endpoint(
|
|
"http://localhost:1234/v1/chat/completions"
|
|
));
|
|
}
|
|
|
|
// ── effective_backend_api_url ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn backend_url_handles_llm_endpoint_overrides() {
|
|
let _guard = env_lock();
|
|
let _env = EnvSnapshot::clear_backend_env();
|
|
let fallback = fallback_backend_base_for_current_build();
|
|
|
|
let cases: &[(&str, &str)] = &[
|
|
(
|
|
"https://api.tinyhumans.ai/openai/v1/chat/completions",
|
|
"https://api.tinyhumans.ai",
|
|
),
|
|
("http://localhost:11434/v1/chat/completions", &fallback),
|
|
("https://api.tinyhumans.ai", "https://api.tinyhumans.ai"),
|
|
(
|
|
"https://api.tinyhumans.ai/openai/v1/",
|
|
"https://api.tinyhumans.ai",
|
|
),
|
|
("https://openrouter.ai/api/v1/chat/completions", &fallback),
|
|
];
|
|
|
|
for (api_url, expected) in cases {
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some(api_url.to_string())),
|
|
*expected,
|
|
"api_url = {api_url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn backend_url_falls_back_for_local_ai_override() {
|
|
let _guard = env_lock();
|
|
let _env = EnvSnapshot::clear_backend_env();
|
|
let expected = fallback_backend_base_for_current_build();
|
|
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some("http://127.0.0.1:11434/v1".to_string())),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_url_falls_back_to_env_when_override_is_local_ai() {
|
|
let _guard = env_lock();
|
|
let _env = EnvSnapshot::clear_backend_env();
|
|
std::env::set_var("BACKEND_URL", "https://staging-api.tinyhumans.ai/");
|
|
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some(
|
|
"http://127.0.0.1:8080/v1/chat/completions".to_string()
|
|
)),
|
|
"https://staging-api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_url_keeps_real_backend_override() {
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some("https://staging-api.tinyhumans.ai/".to_string())),
|
|
"https://staging-api.tinyhumans.ai"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_url_without_override_matches_effective_api_url() {
|
|
let _guard = env_lock();
|
|
let _env = EnvSnapshot::clear_backend_env();
|
|
assert_eq!(effective_backend_api_url(&None), effective_api_url(&None));
|
|
}
|
|
|
|
#[test]
|
|
fn backend_url_strips_inference_path_from_env() {
|
|
// Regression: OPENHUMAN-TAURI-H6 / -HN, issue #2075.
|
|
let _guard = env_lock();
|
|
let _env = EnvSnapshot::clear_backend_env();
|
|
std::env::set_var(
|
|
"BACKEND_URL",
|
|
"https://api.tinyhumans.ai/openai/v1/chat/completions",
|
|
);
|
|
|
|
assert_eq!(
|
|
effective_backend_api_url(&None),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
}
|
|
}
|