diff --git a/src/openhuman/agent/harness/engine/tools.rs b/src/openhuman/agent/harness/engine/tools.rs index 07297ec3b..e50ee999a 100644 --- a/src/openhuman/agent/harness/engine/tools.rs +++ b/src/openhuman/agent/harness/engine/tools.rs @@ -305,17 +305,64 @@ pub(crate) async fn run_one_tool( } } Ok(Err(e)) => { - crate::core::observability::report_error( - &e, - "tool", - "execute", - &[ - ("tool", call.name.as_str()), - ("outcome", "failed"), - ("iteration", &(iteration + 1).to_string()), - ], - ); - (format!("Error executing {}: {e}", call.name), false) + // Distinguish user-state failures (out of credits, missing + // required field, toolkit not enabled, …) from system / product + // failures. Tools that call the integrations backend attach a + // typed `BackendUserStateError` marker for the user-state case + // (see `openhuman::integrations::client`). For these: + // + // 1. Route through `report_error_or_expected` instead of the + // always-capture `report_error`. The breadcrumb classifier + // demotes the buckets we care about + // (`BackendUserError` / `BudgetExhausted` / + // `ProviderUserState`) to a `warn` — Sentry sees one + // demoted breadcrumb per turn instead of the always-error + // capture. We don't *silently* skip — observability still + // sees the failure, it's just classified correctly. + // + // 2. Embed `BACKEND_USER_STATE_MARKER` in the LLM-visible + // result text so the caller-side + // [`crate::openhuman::agent::harness::tool_loop::RepeatFailureGuard`] + // halts the agent loop on the FIRST occurrence with a + // "user must act" message. Without this, the agent flooded + // Sentry with ~19 retries per turn for ~9 users + // (TAURI-RUST-5KG, ~1860 hits) because the per-(tool,args) + // breaker can't catch varying queries and the consecutive + // breaker resets on interleaved success. The real bug isn't + // the Sentry noise — it's the runaway retry. Halting kills + // both. + if crate::openhuman::integrations::is_backend_user_state_error(&e) { + crate::core::observability::report_error_or_expected( + &e, + "tool", + "execute", + &[ + ("tool", call.name.as_str()), + ("outcome", "failed_user_state"), + ("iteration", &(iteration + 1).to_string()), + ], + ); + ( + format!( + "{} Error executing {}: {e}", + super::super::tool_loop::BACKEND_USER_STATE_MARKER, + call.name, + ), + false, + ) + } else { + crate::core::observability::report_error( + &e, + "tool", + "execute", + &[ + ("tool", call.name.as_str()), + ("outcome", "failed"), + ("iteration", &(iteration + 1).to_string()), + ], + ); + (format!("Error executing {}: {e}", call.name), false) + } } Err(_) => { let msg = format!( diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index 54f39377a..27d498c78 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -41,6 +41,29 @@ pub(crate) const NO_PROGRESS_FAILURE_THRESHOLD: u32 = 6; /// and pivot to a different, allowed approach. pub(crate) const HARD_REJECT_REPEAT_THRESHOLD: u32 = 2; +/// Stable marker the tool runner embeds in a failed tool result when the +/// underlying error carries the typed +/// [`crate::openhuman::integrations::BackendUserStateError`] — i.e. the backend +/// returned a deterministic *user-state* failure (insufficient balance, missing +/// required field, toolkit not enabled, sign-in expired, …). +/// +/// Unlike `(tool, args)`-coupled hard rejects, the underlying condition is +/// **global**: the user's wallet is empty, or a toolkit they don't control is +/// disabled. Retrying the same tool with a different query, or pivoting to a +/// different paid integration, cannot resolve it — only the user can. The +/// breaker therefore halts on the **first** occurrence rather than allowing +/// the LLM to grind through a generic [`REPEAT_FAILURE_THRESHOLD`] of +/// indistinguishable failures. +/// +/// This is the actual fix for TAURI-RUST-5KG: `web_search_tool` flooded +/// Sentry with ~19 events/turn × 9 users (1860 hits) because the agent kept +/// retrying after a `400 Insufficient balance` — varying the search query so +/// the per-`(tool,args)` breaker never tripped and interleaving the failures +/// with succeeding tool calls so the consecutive-failure breaker reset. Halt +/// on first occurrence stops the runaway directly instead of just routing +/// the captured errors away from Sentry. +pub(crate) const BACKEND_USER_STATE_MARKER: &str = "[backend-user-state]"; + /// Classification of a deterministic, recognizable policy rejection, detected via /// the stable markers the security/approval layers emit /// ([`crate::openhuman::security::POLICY_BLOCKED_MARKER`] / @@ -108,6 +131,34 @@ impl RepeatFailureGuard { *c += 1; *c }; + // Backend user-state failures (insufficient balance, toolkit disabled, + // missing required field, …) are a *global* deterministic condition + // the agent cannot resolve — only the user can. Halt on the FIRST + // occurrence rather than letting the model burn the generic + // identical-retry threshold by varying args, and rather than waiting + // for the consecutive-failure breaker (which resets on any interleaved + // success). See [`BACKEND_USER_STATE_MARKER`] for the TAURI-RUST-5KG + // background. + if result.contains(BACKEND_USER_STATE_MARKER) { + let clean_reason = result + .replace(BACKEND_USER_STATE_MARKER, "") + .trim() + .to_string(); + tracing::debug!( + tool, + reason = %truncate_for_halt(&clean_reason), + "[circuit_breaker:backend_user_state] halting on first occurrence — global condition, retry futile" + ); + return Some(format!( + "Stopping: the `{tool}` call returned a backend user-state error \ + — this is a deterministic condition that requires user action \ + (e.g. add credits, enable the toolkit, sign in). Retrying \ + with different arguments or a different paid tool will not \ + help. Reason:\n{}\n\nReport this back to the user instead of \ + trying alternative tools.", + truncate_for_halt(&clean_reason), + )); + } // Hard policy rejections trip on the first verbatim repeat; everything // else uses the generic identical-retry threshold. let hard = hard_reject_kind(result); diff --git a/src/openhuman/agent/harness/tool_loop_tests.rs b/src/openhuman/agent/harness/tool_loop_tests.rs index 309a568ba..e026bd7b5 100644 --- a/src/openhuman/agent/harness/tool_loop_tests.rs +++ b/src/openhuman/agent/harness/tool_loop_tests.rs @@ -1107,6 +1107,113 @@ fn hard_reject_distinct_args_do_not_trip_repeat() { ); } +// -- Backend user-state marker (TAURI-RUST-5KG, halt on FIRST occurrence) ------- + +#[test] +fn backend_user_state_marker_halts_on_first_occurrence() { + // The actual fix for TAURI-RUST-5KG (`web_search_tool` flooded Sentry with + // ~1860 events because the agent retried 19× per turn on a 400 Insufficient + // balance). Unlike hard policy rejects (which let the model try once and + // halt on the verbatim repeat), the underlying condition is *global* — + // varying the query or pivoting to a different paid tool cannot resolve + // an empty wallet. Halt immediately. + // `BACKEND_USER_STATE_MARKER` is `pub(crate)` in the parent `tool_loop` + // module and reached here via `use super::*;` at the top of this file. + let mut g = RepeatFailureGuard::new(); + let body = format!( + "{BACKEND_USER_STATE_MARKER} Error executing web_search_tool: \ + Backend returned 400 Bad Request for POST /agent-integrations/parallel/search: \ + Insufficient balance" + ); + let halt = g.record( + "web_search_tool", + "{\"query\":\"latest news\"}", + false, + &body, + ); + assert!( + halt.is_some(), + "first backend-user-state failure must halt — retries can never resolve a global condition" + ); + let msg = halt.unwrap(); + assert!( + msg.contains("backend user-state error"), + "halt summary should label the failure class; got: {msg}" + ); + assert!( + msg.contains("requires user action"), + "halt summary should tell the model to surface it to the user; got: {msg}" + ); + assert!( + msg.contains("Insufficient balance"), + "halt summary should preserve the actionable root cause; got: {msg}" + ); + assert!( + !msg.contains(BACKEND_USER_STATE_MARKER), + "internal routing token must not leak into user-visible halt message; got: {msg}" + ); +} + +#[test] +fn backend_user_state_marker_halts_regardless_of_args() { + // The whole point of the first-occurrence semantic: the failure is global, + // so a *different* query that re-hits the same condition is still doomed. + // Pin that the breaker doesn't wait for an identical `(tool, args)` repeat + // the way the generic [`REPEAT_FAILURE_THRESHOLD`] would. + // `BACKEND_USER_STATE_MARKER` is `pub(crate)` in the parent `tool_loop` + // module and reached here via `use super::*;` at the top of this file. + let mut g = RepeatFailureGuard::new(); + let body = format!("{BACKEND_USER_STATE_MARKER} Error: Insufficient balance"); + assert!( + g.record("web_search_tool", "{\"query\":\"q1\"}", false, &body) + .is_some(), + "first call with a backend-user-state marker must halt even though \ + (tool, args) hasn't repeated yet" + ); +} + +#[test] +fn backend_user_state_marker_takes_precedence_over_generic_threshold() { + // If we ever broke ordering and the marker check sat *after* the generic + // `(tool, args)` repeat-threshold gate, a first-occurrence user-state + // failure would silently fall through to "not enough repeats yet" and the + // agent would keep retrying — defeating the whole point. Lock the order + // in with a direct assertion. + // `BACKEND_USER_STATE_MARKER` is `pub(crate)` in the parent `tool_loop` + // module and reached here via `use super::*;` at the top of this file. + let mut g = RepeatFailureGuard::new(); + // Same (tool, args, body) shape that the closed PR would have allowed + // through 2 more times. With the marker check first, count=1 already + // trips. count==1 < REPEAT_FAILURE_THRESHOLD==3 → without the new gate + // this test would fail. + let body = format!("{BACKEND_USER_STATE_MARKER} Insufficient balance"); + let halt = g.record("web_search_tool", "{}", false, &body); + assert!(halt.is_some(), "count=1 user-state failure must halt now"); +} + +#[test] +fn backend_user_state_unmarked_failures_use_normal_threshold() { + // Regression guard: a tool error that *doesn't* carry the marker must + // continue to use the generic 3-attempt repeat threshold. The new gate + // should not widen to affect ordinary tool failures. + let mut g = RepeatFailureGuard::new(); + // Backend-shaped 5xx — no marker, since `classify_as_user_state` rejects + // these and `is_backend_user_state_error` is false → no prefix added. + let body = "Error executing tool_x: Backend returned 500 for POST /foo: upstream blew up"; + assert!( + g.record("tool_x", "{}", false, body).is_none(), + "first non-marker failure should not halt" + ); + assert!( + g.record("tool_x", "{}", false, body).is_none(), + "second non-marker failure should not halt" + ); + assert!( + g.record("tool_x", "{}", false, body).is_some(), + "third identical non-marker failure must halt via the 3-attempt threshold" + ); +} + /// Provider that records the tool-spec names of every `chat()` request /// it sees, then returns the next scripted response. struct CapturingProvider { diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index 4e24aa924..7aeca0bc9 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -2,9 +2,87 @@ use super::types::{BackendResponse, IntegrationPricing}; use std::error::Error as _; +use std::fmt; use std::sync::Arc; use std::time::Duration; +/// Typed marker error attached to `anyhow::Error` when the backend returned a +/// deterministic user-state failure (insufficient balance, missing-required- +/// field, toolkit-not-enabled, …) classified by +/// [`crate::core::observability::expected_error_kind`] as +/// `BackendUserError` / `BudgetExhausted` / `ProviderUserState`. +/// +/// The point is *not* to change what the error renders as — the `Display` +/// impl reproduces the same `Backend returned 400 …: ` shape the +/// `anyhow::bail!` call would have produced, so existing callers that just +/// stringify the error see no behaviour change. The point is to let the +/// agent tool runner (`agent::harness::engine::tools`) downcast and route +/// the failure to the warn-only path instead of `report_error`, so a tool +/// call that fails 19 times in one turn because the user is out of credits +/// doesn't generate 19 Sentry events (TAURI-RUST-5KG). +/// +/// User-state vs system failure is a contract decision that belongs at the +/// boundary that knows the difference — the HTTP client, which has both the +/// status code and the body to classify. Bubbling everything as an opaque +/// `anyhow::Error` and re-classifying at every call site is what caused the +/// regression to begin with. +#[derive(Debug, Clone)] +pub struct BackendUserStateError { + pub message: String, +} + +impl fmt::Display for BackendUserStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BackendUserStateError {} + +/// Returns `true` when `err`'s chain contains a [`BackendUserStateError`]. +/// +/// Use at tool-runner / Sentry-capture sites that hold an `anyhow::Error` +/// and need to decide whether to report-as-bug or treat-as-clean-failure. +pub fn is_backend_user_state_error(err: &anyhow::Error) -> bool { + err.downcast_ref::().is_some() + || err + .chain() + .any(|cause| cause.downcast_ref::().is_some()) +} + +/// Classify `message` via [`crate::core::observability::expected_error_kind`] +/// and return `Some` of an `anyhow::Error` wrapping [`BackendUserStateError`] +/// when the kind is one of the user-state buckets that must not reach +/// Sentry. Returns `None` when the message is genuinely unexpected (5xx, +/// unknown shapes, transport bugs) so the caller falls back to +/// `anyhow::bail!` and the existing capture path runs. +/// +/// Buckets considered user-state here: +/// - `BackendUserError` — generic 4xx with classified body (insufficient +/// balance, generic "missing required fields", …). +/// - `BudgetExhausted` — "insufficient balance" / "budget exceeded" / +/// "add credits" — the user can't proceed without topping up. +/// - `ProviderUserState` — composio "toolkit not enabled", trigger slug +/// missing, OAuth scopes missing, etc. +/// +/// Other expected kinds (`NetworkUnreachable`, `TransientUpstreamHttp`, +/// `SessionExpired`, …) are *also* demoted by the breadcrumb classifier but +/// represent transport / session conditions, not a clean user-state failure +/// the tool should report-and-stop on; we leave them as plain anyhow so the +/// existing retry / re-auth machinery keeps working. +fn classify_as_user_state(message: &str) -> Option { + use crate::core::observability::{expected_error_kind, ExpectedErrorKind}; + + match expected_error_kind(message)? { + ExpectedErrorKind::BackendUserError + | ExpectedErrorKind::BudgetExhausted + | ExpectedErrorKind::ProviderUserState => Some(anyhow::Error::new(BackendUserStateError { + message: message.to_string(), + })), + _ => None, + } +} + /// Maximum length (in bytes) of backend error body included in propagated /// errors. Keep this bounded — error messages flow through tracing/Sentry and /// are surfaced in user-facing toasts, neither of which want a 100KB blob. @@ -204,8 +282,9 @@ impl IntegrationClient { // firing a Sentry event. 5xx and non-transient 4xx still // surface — see `is_backend_user_error_message` for the exact // status set classified as expected. + let bail_message = format!("Backend returned {status} for POST {url}: {detail}"); crate::core::observability::report_error_or_expected( - format!("Backend returned {status} for POST {url}: {detail}").as_str(), + bail_message.as_str(), "integrations", "post", &[ @@ -214,7 +293,16 @@ impl IntegrationClient { ("failure", "non_2xx"), ], ); - anyhow::bail!("Backend returned {status} for POST {url}: {detail}"); + // When the failure classifies as a known user-state bucket, + // attach the typed `BackendUserStateError` marker so downstream + // sites (notably `agent::harness::engine::tools`) can downcast + // and route to the warn-only path instead of re-capturing in + // Sentry. Display string is preserved → unchanged for the many + // callers that only stringify the error. + if let Some(typed) = classify_as_user_state(&bail_message) { + return Err(typed); + } + anyhow::bail!(bail_message); } let envelope: BackendResponse = resp.json().await?; @@ -235,7 +323,11 @@ impl IntegrationClient { "post", &[("path", path), ("failure", "envelope_error")], ); - anyhow::bail!("Backend error for POST {}: {}", url, msg); + let bail_message = format!("Backend error for POST {}: {}", url, msg); + if let Some(typed) = classify_as_user_state(&bail_message) { + return Err(typed); + } + anyhow::bail!(bail_message); } envelope .data @@ -284,8 +376,9 @@ impl IntegrationClient { // user-input / auth-state shapes demote to a warn breadcrumb // via the observability classifier; 5xx and non-transient 4xx // still surface. + let bail_message = format!("Backend returned {status} for GET {url}: {detail}"); crate::core::observability::report_error_or_expected( - format!("Backend returned {status} for GET {url}: {detail}").as_str(), + bail_message.as_str(), "integrations", "get", &[ @@ -294,7 +387,10 @@ impl IntegrationClient { ("failure", "non_2xx"), ], ); - anyhow::bail!("Backend returned {status} for GET {url}: {detail}"); + if let Some(typed) = classify_as_user_state(&bail_message) { + return Err(typed); + } + anyhow::bail!(bail_message); } let envelope: BackendResponse = resp.json().await?; @@ -311,7 +407,11 @@ impl IntegrationClient { "get", &[("path", path), ("failure", "envelope_error")], ); - anyhow::bail!("Backend error for GET {}: {}", url, msg); + let bail_message = format!("Backend error for GET {}: {}", url, msg); + if let Some(typed) = classify_as_user_state(&bail_message) { + return Err(typed); + } + anyhow::bail!(bail_message); } envelope .data diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index be618c68b..0e4ef5e19 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -393,6 +393,205 @@ async fn jira_generic_400_classifies_as_backend_user_error() { // ── Unit: `sanitize_backend_url` (issue #2075) ──────────────────── +// ── TAURI-RUST-5KG: typed BackendUserStateError boundary ──────────── +// +// 1860 Sentry events / 9 users from `web_search_tool` → backend 400 +// "Insufficient balance". The integrations breadcrumb path already +// demoted the event, but the per-call error bubbled up as a flat +// `anyhow::Error` and the agent's tool runner re-captured it. The fix +// types the error here so the runner can `downcast_ref::<…>()` and +// route to the warn-only path. These tests pin both halves of the +// contract: (a) classify-and-wrap fires on user-state failures, +// (b) Display string is preserved for stringify-only callers, and +// (c) genuine system failures stay un-typed so capture still works. + +#[tokio::test] +async fn post_400_insufficient_balance_returns_typed_backend_user_state_error() { + let app = Router::new().route( + "/agent-integrations/parallel/search", + post(|| async { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "success": false, "error": "Insufficient balance" })), + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + let err = client + .post::( + "/agent-integrations/parallel/search", + &json!({ "objective": "test" }), + ) + .await + .expect_err("400 must surface as Err"); + + // Typed: the agent tool runner relies on this exact downcast to + // route the failure to the warn-only path instead of `report_error`. + assert!( + is_backend_user_state_error(&err), + "400 'Insufficient balance' must carry BackendUserStateError marker so the \ + tool runner can route it to the warn-only path (TAURI-RUST-5KG); got: {err:#}" + ); + + // Display preserved: every caller that just stringifies the error + // (toasts, logs, prior bail-format consumers) keeps seeing the same + // message — typing is purely additive. + let msg = format!("{err:#}"); + assert!( + msg.contains("Insufficient balance"), + "Display string must still carry the user-facing error; got: {msg}" + ); + assert!( + msg.contains("400"), + "Display string must still carry the HTTP status; got: {msg}" + ); +} + +#[tokio::test] +async fn post_500_internal_error_is_not_marked_user_state() { + let app = Router::new().route( + "/foo", + post(|| async { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "upstream blew up", + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + let err = client + .post::("/foo", &json!({})) + .await + .expect_err("500 must surface as Err"); + + // 5xx is a real failure — must remain a plain anyhow error so + // `report_error` runs at the tool runner and triage sees it. + assert!( + !is_backend_user_state_error(&err), + "5xx must NOT carry the user-state marker — that would silence real \ + backend bugs; got: {err:#}" + ); +} + +#[tokio::test] +async fn get_403_toolkit_not_enabled_returns_typed_backend_user_state_error() { + // Composio "Toolkit X is not enabled" classifies as + // `ProviderUserState` per the observability matcher. Pin that the + // typed marker is attached for the entire user-state bucket family, + // not just BackendUserError / BudgetExhausted. + let app = Router::new().route( + "/agent-integrations/composio/connections", + get(|| async { + ( + StatusCode::FORBIDDEN, + Json(json!({ "success": false, "error": "Toolkit \"slack\" is not enabled" })), + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + let err = client + .get::("/agent-integrations/composio/connections") + .await + .expect_err("403 must surface as Err"); + + assert!( + is_backend_user_state_error(&err), + "Provider-user-state 403 must carry BackendUserStateError marker; got: {err:#}" + ); + let msg = format!("{err:#}"); + assert!( + msg.contains("Toolkit \"slack\" is not enabled"), + "Display must preserve the actionable error; got: {msg}" + ); +} + +#[tokio::test] +async fn post_envelope_user_state_failure_returns_typed_backend_user_state_error() { + // 2xx + `success: false` user-state envelope failure (composio + // "Toolkit X is not enabled" wire shape on the 2xx path). The + // envelope-error branch must wrap with the typed marker too — + // otherwise the runner re-captures it on the next tool call. + let app = Router::new().route( + "/agent-integrations/composio/execute", + post(|| async { + ( + StatusCode::OK, + Json(json!({ + "success": false, + "error": "Toolkit \"slack\" is not enabled" + })), + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + let err = client + .post::("/agent-integrations/composio/execute", &json!({})) + .await + .expect_err("envelope-failure must surface as Err"); + + assert!( + is_backend_user_state_error(&err), + "envelope user-state failure must carry BackendUserStateError marker; \ + got: {err:#}" + ); +} + +#[test] +fn backend_user_state_error_display_is_message_verbatim() { + let typed = BackendUserStateError { + message: "Backend returned 400 Bad Request for POST x: Insufficient balance".into(), + }; + // The Display impl is the single source of truth for what callers + // see; an `anyhow::Error::new(typed)` rendering must match this + // exactly so the existing bail-format contract holds. + assert_eq!( + typed.to_string(), + "Backend returned 400 Bad Request for POST x: Insufficient balance" + ); +} + +#[test] +fn is_backend_user_state_error_matches_wrapped_anyhow() { + // `anyhow::Error::new(typed)` puts the typed value at the root of + // the chain — confirm both the direct downcast and the chain walk + // catch it (defense-in-depth against future `.context(…)` wraps). + let typed = BackendUserStateError { + message: "x".into(), + }; + let err: anyhow::Error = typed.into(); + assert!(is_backend_user_state_error(&err)); + + let plain = anyhow::anyhow!("not user-state"); + assert!(!is_backend_user_state_error(&plain)); +} + +#[test] +fn is_backend_user_state_error_finds_typed_marker_through_context_wraps() { + // Defense-in-depth: if a caller wraps the typed error with + // `.context("more info")`, the marker still lives in the chain. + // `is_backend_user_state_error` must walk to find it — otherwise + // any future `with_context` at a call site silently re-enables + // Sentry capture for user-state failures. + use anyhow::Context; + + let typed = BackendUserStateError { + message: "Backend returned 400 …: Insufficient balance".into(), + }; + let err: anyhow::Error = anyhow::Error::new(typed).context("while executing web_search_tool"); + assert!( + is_backend_user_state_error(&err), + "marker must be reachable after .context() wraps; got: {err:#}" + ); +} + #[test] fn sanitize_backend_url_strips_inference_path() { // Regression: a misconfigured `BACKEND_URL` baked into the build diff --git a/src/openhuman/integrations/mod.rs b/src/openhuman/integrations/mod.rs index da78efeff..b7c92c3f6 100644 --- a/src/openhuman/integrations/mod.rs +++ b/src/openhuman/integrations/mod.rs @@ -10,7 +10,10 @@ pub mod client; pub mod tools; pub mod types; -pub use client::{build_client, pricing_for_config, IntegrationClient}; +pub use client::{ + build_client, is_backend_user_state_error, pricing_for_config, BackendUserStateError, + IntegrationClient, +}; pub use types::{ BackendResponse, IntegrationPricing, IntegrationPricingEntry, PricingIntegrations, ToolScope, };