+
-
+
{teamUsage.cycleBudgetUsd > 0
? `${t('chat.weeklyLimitHit')}${teamUsage.cycleEndsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} ${t('chat.topUpToContinue')}`
: t('chat.budgetComplete')}
-
+
+
+
+
+
+ )}
+ {openRouterStatus === 'error' && (
+
+ {t('openrouterFree.error')}
)}
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx
index 73bfdfb42..72ca667d6 100644
--- a/app/src/pages/Home.tsx
+++ b/app/src/pages/Home.tsx
@@ -10,6 +10,7 @@ import {
import { useUsageState } from '../hooks/useUsageState';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
+import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels';
import { restartCoreProcess } from '../services/coreProcessControl';
import { selectBlockingState } from '../store/connectivitySelectors';
import { useAppDispatch, useAppSelector } from '../store/hooks';
@@ -64,6 +65,7 @@ const Home = () => {
const blocking = useAppSelector(selectBlockingState);
const [isRestartingCore, setIsRestartingCore] = useState(false);
const [restartError, setRestartError] = useState
(null);
+ const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.theme.mode) as ThemeMode;
@@ -85,6 +87,18 @@ const Home = () => {
}
};
+ const handleUseOpenRouterFree = async () => {
+ setOpenRouterStatus('saving');
+ try {
+ await applyOpenRouterFreeModels();
+ setOpenRouterStatus('idle');
+ navigate('/chat');
+ } catch (err) {
+ console.warn('[home] applyOpenRouterFreeModels failed', err);
+ setOpenRouterStatus('error');
+ }
+ };
+
const statusCopy = {
ok: t('home.statusOk'),
'backend-only': t('home.statusBackendOnly'),
@@ -143,8 +157,21 @@ const Home = () => {
title={t('home.usageExhaustedTitle')}
message={t('home.usageExhaustedBody')}
ctaLabel={t('home.usageExhaustedCta')}
+ secondaryCtaLabel={
+ openRouterStatus === 'saving' ? t('openrouterFree.saving') : t('openrouterFree.cta')
+ }
+ onSecondaryCtaClick={() => {
+ if (openRouterStatus !== 'saving') {
+ void handleUseOpenRouterFree();
+ }
+ }}
/>
)}
+ {openRouterStatus === 'error' && (
+
+ {t('openrouterFree.error')}
+
+ )}
{showPromoBanner && }
diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx
index f8816999c..24f1ebb9c 100644
--- a/app/src/pages/__tests__/Conversations.render.test.tsx
+++ b/app/src/pages/__tests__/Conversations.render.test.tsx
@@ -50,6 +50,7 @@ const { mockGetThreads, mockGetThreadMessages, mockUseUsageState } = vi.hoisted(
refresh: vi.fn(),
})),
}));
+const mockUseOpenRouterFreeModels = vi.hoisted(() => vi.fn());
// ── Module mocks ───────────────────────────────────────────────────────────
@@ -118,6 +119,10 @@ vi.mock('../../services/api/agentProfilesApi', () => ({
},
}));
+vi.mock('../../services/api/openrouterFreeModels', () => ({
+ applyOpenRouterFreeModels: () => mockUseOpenRouterFreeModels(),
+}));
+
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
vi.mock('../../store/socketSelectors', () => ({
@@ -429,6 +434,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// Budget-exceeded banner (lines 1417-1439) — cycleBudgetUsd=0 gives "included budget" message
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument();
// LimitPill renders with the cycle label
expect(screen.getByText('Cycle')).toBeInTheDocument();
@@ -626,6 +632,37 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
expect(openUrl).toHaveBeenCalled();
});
+ it('clicking OpenRouter free models in the budget banner routes chat workloads', async () => {
+ const teamUsage = { cycleBudgetUsd: 10, remainingUsd: 0, cycleSpentUsd: 10, cycleEndsAt: null };
+ mockUseOpenRouterFreeModels.mockResolvedValueOnce(undefined);
+
+ mockUseUsageState.mockReturnValue({
+ teamUsage,
+ currentPlan: null,
+ currentTier: 'PRO' as const,
+ isFreeTier: false,
+ usagePct: 1.0,
+ isNearLimit: true,
+ isAtLimit: true,
+ isBudgetExhausted: true,
+ shouldShowBudgetCompletedMessage: true,
+ isLoading: false,
+ refresh: vi.fn(),
+ });
+
+ await act(async () => {
+ await renderConversations({ thread: emptyThreadState });
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /Use OpenRouter free models/i }));
+ });
+
+ await waitFor(() => {
+ expect(mockUseOpenRouterFreeModels).toHaveBeenCalledTimes(1);
+ });
+ });
+
it('handles /new from the composer without a selected thread or sending chat text', async () => {
mockGetThreads.mockReturnValue(new Promise(() => {}));
diff --git a/app/src/pages/__tests__/Home.test.tsx b/app/src/pages/__tests__/Home.test.tsx
index 871019781..afb9df1dd 100644
--- a/app/src/pages/__tests__/Home.test.tsx
+++ b/app/src/pages/__tests__/Home.test.tsx
@@ -21,6 +21,11 @@ const mockUseUsageState = vi.hoisted(() =>
);
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
+const mockUseOpenRouterFreeModels = vi.hoisted(() => vi.fn());
+vi.mock('../../services/api/openrouterFreeModels', () => ({
+ applyOpenRouterFreeModels: () => mockUseOpenRouterFreeModels(),
+}));
+
// Default: return 'ok' so most tests see the normal state. The
// blocking-state selector is the only thing this mock is asked to
// resolve from the live code; Home.tsx also reads `theme.mode`, which
@@ -215,5 +220,20 @@ describe('Home page — budget completed banner', () => {
expect(screen.getByText(/Exhausted Your Usage/i)).toBeInTheDocument();
expect(screen.getByText(/out of included usage/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument();
+ });
+
+ it('clicking OpenRouter free models runs the routing helper', async () => {
+ mockUseUsageState.mockReturnValueOnce({ shouldShowBudgetCompletedMessage: true });
+ mockUseOpenRouterFreeModels.mockResolvedValueOnce(undefined);
+
+ const { default: Home } = await import('../Home');
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /Use OpenRouter free models/i }));
+
+ await waitFor(() => {
+ expect(mockUseOpenRouterFreeModels).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/app/src/services/api/__tests__/openrouterFreeModels.test.ts b/app/src/services/api/__tests__/openrouterFreeModels.test.ts
new file mode 100644
index 000000000..63f9cd4df
--- /dev/null
+++ b/app/src/services/api/__tests__/openrouterFreeModels.test.ts
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { applyOpenRouterFreeModels, OPENROUTER_FREE_MODEL } from '../openrouterFreeModels';
+
+const {
+ mockConnectOpenRouterViaOAuth,
+ mockLoadAISettings,
+ mockSaveAISettings,
+ mockSetCloudProviderKey,
+ mockRequestUsageRefresh,
+} = vi.hoisted(() => ({
+ mockConnectOpenRouterViaOAuth: vi.fn(),
+ mockLoadAISettings: vi.fn(),
+ mockSaveAISettings: vi.fn(),
+ mockSetCloudProviderKey: vi.fn(),
+ mockRequestUsageRefresh: vi.fn(),
+}));
+
+vi.mock('../../../utils/openrouterOAuth', () => ({
+ connectOpenRouterViaOAuth: () => mockConnectOpenRouterViaOAuth(),
+}));
+
+vi.mock('../../../hooks/usageRefresh', () => ({
+ requestUsageRefresh: () => mockRequestUsageRefresh(),
+}));
+
+vi.mock('../aiSettingsApi', async () => {
+ const actual = await vi.importActual('../aiSettingsApi');
+ return {
+ ...actual,
+ loadAISettings: () => mockLoadAISettings(),
+ saveAISettings: (...args: unknown[]) => mockSaveAISettings(...args),
+ setCloudProviderKey: (...args: unknown[]) => mockSetCloudProviderKey(...args),
+ };
+});
+
+function settings(hasOpenRouterKey = false) {
+ return {
+ cloudProviders: hasOpenRouterKey
+ ? [
+ {
+ id: 'p_openrouter',
+ slug: 'openrouter',
+ label: 'OpenRouter',
+ endpoint: 'https://openrouter.ai/api/v1',
+ auth_style: 'bearer',
+ has_api_key: true,
+ },
+ ]
+ : [],
+ routing: {
+ chat: { kind: 'openhuman' },
+ reasoning: { kind: 'openhuman' },
+ agentic: { kind: 'openhuman' },
+ coding: { kind: 'openhuman' },
+ memory: { kind: 'openhuman' },
+ heartbeat: { kind: 'openhuman' },
+ learning: { kind: 'openhuman' },
+ subconscious: { kind: 'openhuman' },
+ },
+ };
+}
+
+describe('applyOpenRouterFreeModels', () => {
+ beforeEach(() => {
+ mockConnectOpenRouterViaOAuth.mockReset();
+ mockLoadAISettings.mockReset();
+ mockSaveAISettings.mockReset();
+ mockSetCloudProviderKey.mockReset();
+ mockRequestUsageRefresh.mockReset();
+ });
+
+ it('routes chat workloads to the OpenRouter free router when a key already exists', async () => {
+ const current = settings(true);
+ mockLoadAISettings.mockResolvedValue(current);
+
+ await applyOpenRouterFreeModels();
+
+ expect(mockConnectOpenRouterViaOAuth).not.toHaveBeenCalled();
+ expect(mockSetCloudProviderKey).not.toHaveBeenCalled();
+ expect(mockSaveAISettings).toHaveBeenCalledWith(
+ current,
+ expect.objectContaining({
+ routing: expect.objectContaining({
+ chat: { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL },
+ reasoning: { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL },
+ agentic: { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL },
+ coding: { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL },
+ memory: { kind: 'openhuman' },
+ }),
+ })
+ );
+ expect(mockRequestUsageRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('connects OpenRouter before routing when no key is stored', async () => {
+ const current = settings(false);
+ mockLoadAISettings.mockResolvedValue(current);
+ mockConnectOpenRouterViaOAuth.mockResolvedValue('sk-or-test');
+
+ await applyOpenRouterFreeModels();
+
+ expect(mockConnectOpenRouterViaOAuth).toHaveBeenCalledTimes(1);
+ expect(mockSetCloudProviderKey).toHaveBeenCalledWith('openrouter', 'sk-or-test');
+ expect(mockSaveAISettings).toHaveBeenCalledWith(
+ current,
+ expect.objectContaining({
+ cloudProviders: [
+ expect.objectContaining({
+ slug: 'openrouter',
+ endpoint: 'https://openrouter.ai/api/v1',
+ has_api_key: true,
+ }),
+ ],
+ })
+ );
+ });
+});
diff --git a/app/src/services/api/openrouterFreeModels.ts b/app/src/services/api/openrouterFreeModels.ts
new file mode 100644
index 000000000..ac53c9fdf
--- /dev/null
+++ b/app/src/services/api/openrouterFreeModels.ts
@@ -0,0 +1,60 @@
+import { requestUsageRefresh } from '../../hooks/usageRefresh';
+import { connectOpenRouterViaOAuth } from '../../utils/openrouterOAuth';
+import {
+ type AISettings,
+ CHAT_WORKLOADS,
+ type CloudProviderView,
+ loadAISettings,
+ type ProviderRef,
+ saveAISettings,
+ setCloudProviderKey,
+} from './aiSettingsApi';
+
+export const OPENROUTER_FREE_MODEL = 'openrouter/free';
+
+function openRouterProvider(): CloudProviderView {
+ return {
+ id: 'builtin_openrouter',
+ slug: 'openrouter',
+ label: 'OpenRouter',
+ endpoint: 'https://openrouter.ai/api/v1',
+ auth_style: 'bearer',
+ has_api_key: true,
+ };
+}
+
+function withOpenRouterProvider(settings: AISettings): AISettings {
+ const existing = settings.cloudProviders.find(p => p.slug === 'openrouter');
+ if (existing) {
+ return {
+ ...settings,
+ cloudProviders: settings.cloudProviders.map(p =>
+ p.slug === 'openrouter' ? { ...p, has_api_key: true } : p
+ ),
+ };
+ }
+ return { ...settings, cloudProviders: [...settings.cloudProviders, openRouterProvider()] };
+}
+
+export async function applyOpenRouterFreeModels(): Promise {
+ const current = await loadAISettings();
+ const existingOpenRouter = current.cloudProviders.find(p => p.slug === 'openrouter');
+ if (!existingOpenRouter?.has_api_key) {
+ const apiKey = await connectOpenRouterViaOAuth();
+ await setCloudProviderKey('openrouter', apiKey);
+ }
+
+ const withProvider = withOpenRouterProvider(current);
+ const next: AISettings = {
+ ...withProvider,
+ routing: {
+ ...withProvider.routing,
+ ...CHAT_WORKLOADS.reduce>((acc, workload) => {
+ acc[workload] = { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL };
+ return acc;
+ }, {}),
+ },
+ };
+ await saveAISettings(current, next);
+ requestUsageRefresh();
+}
diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs
index 5133bfacc..395e5d56c 100644
--- a/src/openhuman/inference/provider/compatible.rs
+++ b/src/openhuman/inference/provider/compatible.rs
@@ -2182,6 +2182,7 @@ impl Provider for OpenAiCompatibleProvider {
let client = self.http_client();
let auth_header = self.auth_header.clone();
let extra_headers = self.extra_headers.clone();
+ let openrouter_attribution_headers = self.openrouter_attribution_headers();
let provider_name = self.name.clone();
let model_owned = model.to_string();
@@ -2212,6 +2213,11 @@ impl Provider for OpenAiCompatibleProvider {
for (name, value) in &extra_headers {
req_builder = req_builder.header(name.as_str(), value.as_str());
}
+ if let Some((referer, title)) = openrouter_attribution_headers {
+ req_builder = req_builder
+ .header("HTTP-Referer", referer)
+ .header("X-OpenRouter-Title", title);
+ }
// Set accept header for streaming
req_builder = req_builder.header("Accept", "text/event-stream");
@@ -2354,6 +2360,8 @@ impl Provider for OpenAiCompatibleProvider {
let url = self.chat_completions_url();
let client = self.http_client();
let auth_header = self.auth_header.clone();
+ let extra_headers = self.extra_headers.clone();
+ let openrouter_attribution_headers = self.openrouter_attribution_headers();
let provider_name = self.name.clone();
let model_owned = model.to_string();
@@ -2376,6 +2384,14 @@ impl Provider for OpenAiCompatibleProvider {
req_builder.header(header, credential)
}
};
+ for (name, value) in &extra_headers {
+ req_builder = req_builder.header(name.as_str(), value.as_str());
+ }
+ if let Some((referer, title)) = openrouter_attribution_headers {
+ req_builder = req_builder
+ .header("HTTP-Referer", referer)
+ .header("X-OpenRouter-Title", title);
+ }
req_builder = req_builder.header("Accept", "text/event-stream");
let response = match req_builder.send().await {
diff --git a/src/openhuman/inference/provider/compatible_request.rs b/src/openhuman/inference/provider/compatible_request.rs
index 7402db743..67d0d62b9 100644
--- a/src/openhuman/inference/provider/compatible_request.rs
+++ b/src/openhuman/inference/provider/compatible_request.rs
@@ -8,6 +8,9 @@ use crate::openhuman::inference::provider::{temperature, thread_context};
use super::{AuthStyle, OpenAiCompatibleProvider};
+const OPENROUTER_REFERER: &str = "https://openhuman.ai";
+const OPENROUTER_TITLE: &str = "OpenHuman";
+
impl OpenAiCompatibleProvider {
/// Build the Ollama-specific `options` block for the request body.
/// Returns `None` when no `num_ctx` override is configured.
@@ -275,7 +278,7 @@ impl OpenAiCompatibleProvider {
.header("anthropic-version", "2023-06-01"),
(AuthStyle::Custom(header), Some(credential)) => req.header(header, credential),
};
- self.apply_extra_headers(req)
+ self.apply_openrouter_attribution_headers(self.apply_extra_headers(req))
}
pub(super) fn apply_extra_headers(
@@ -290,4 +293,32 @@ impl OpenAiCompatibleProvider {
}
req
}
+
+ pub(super) fn is_openrouter_endpoint(&self) -> bool {
+ if self.name.eq_ignore_ascii_case("openrouter") {
+ return true;
+ }
+
+ reqwest::Url::parse(&self.base_url)
+ .ok()
+ .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
+ .is_some_and(|host| host == "openrouter.ai" || host.ends_with(".openrouter.ai"))
+ }
+
+ pub(super) fn apply_openrouter_attribution_headers(
+ &self,
+ req: reqwest::RequestBuilder,
+ ) -> reqwest::RequestBuilder {
+ if let Some((referer, title)) = self.openrouter_attribution_headers() {
+ req.header("HTTP-Referer", referer)
+ .header("X-OpenRouter-Title", title)
+ } else {
+ req
+ }
+ }
+
+ pub(super) fn openrouter_attribution_headers(&self) -> Option<(&'static str, &'static str)> {
+ self.is_openrouter_endpoint()
+ .then_some((OPENROUTER_REFERER, OPENROUTER_TITLE))
+ }
}
diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs
index 5231c470a..591d03993 100644
--- a/src/openhuman/inference/provider/compatible_tests.rs
+++ b/src/openhuman/inference/provider/compatible_tests.rs
@@ -465,6 +465,60 @@ fn extra_headers_are_applied_with_auth_header() {
);
}
+#[test]
+fn openrouter_requests_include_app_attribution_headers() {
+ let p = OpenAiCompatibleProvider::new(
+ "openrouter",
+ "https://openrouter.ai/api/v1",
+ Some("sk-or-test"),
+ AuthStyle::Bearer,
+ );
+
+ let req = p
+ .apply_auth_header(
+ p.http_client()
+ .post("https://openrouter.ai/api/v1/chat/completions"),
+ Some("sk-or-test"),
+ )
+ .build()
+ .unwrap();
+
+ assert_eq!(
+ req.headers()
+ .get("HTTP-Referer")
+ .and_then(|value| value.to_str().ok()),
+ Some("https://openhuman.ai")
+ );
+ assert_eq!(
+ req.headers()
+ .get("X-OpenRouter-Title")
+ .and_then(|value| value.to_str().ok()),
+ Some("OpenHuman")
+ );
+}
+
+#[test]
+fn non_openrouter_requests_do_not_include_openrouter_attribution_headers() {
+ let p = OpenAiCompatibleProvider::new(
+ "custom",
+ "https://api.example.com/v1",
+ Some("test-key"),
+ AuthStyle::Bearer,
+ );
+
+ let req = p
+ .apply_auth_header(
+ p.http_client()
+ .post("https://api.example.com/v1/chat/completions"),
+ Some("test-key"),
+ )
+ .build()
+ .unwrap();
+
+ assert!(req.headers().get("HTTP-Referer").is_none());
+ assert!(req.headers().get("X-OpenRouter-Title").is_none());
+}
+
#[test]
fn extra_query_params_are_applied_to_codex_urls() {
let p = OpenAiCompatibleProvider::new(
diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs
index ab77f8d57..4e24aa924 100644
--- a/src/openhuman/integrations/client.rs
+++ b/src/openhuman/integrations/client.rs
@@ -37,6 +37,10 @@ pub(crate) fn extract_error_detail(body: &str, max_bytes: usize) -> String {
crate::openhuman::util::truncate_at_byte_boundary(body, max_bytes)
}
+fn managed_budget_applies_to_path(path: &str) -> bool {
+ path != "/agent-integrations/pricing" && path.starts_with("/agent-integrations/")
+}
+
/// Strip any inference-style path that snuck into a backend URL before
/// it becomes the [`IntegrationClient::backend_url`] field. Idempotent —
/// returns the input unchanged when already clean.
@@ -74,12 +78,29 @@ fn sanitize_backend_url(backend_url: &str) -> String {
pub struct IntegrationClient {
pub backend_url: String,
pub auth_token: String,
+ budget_config: Option>,
http_client: reqwest::Client,
pricing: tokio::sync::OnceCell,
}
impl IntegrationClient {
pub fn new(backend_url: String, auth_token: String) -> Self {
+ Self::new_inner(backend_url, auth_token, None)
+ }
+
+ pub fn new_with_budget_config(
+ backend_url: String,
+ auth_token: String,
+ config: Arc,
+ ) -> Self {
+ Self::new_inner(backend_url, auth_token, Some(config))
+ }
+
+ fn new_inner(
+ backend_url: String,
+ auth_token: String,
+ budget_config: Option>,
+ ) -> Self {
// Defense-in-depth (issue #2075 / Sentry OPENHUMAN-TAURI-H6, -HN):
// every prod call site routes `backend_url` through
// `effective_backend_api_url` which strips inference-style paths,
@@ -106,17 +127,33 @@ impl IntegrationClient {
Self {
backend_url,
auth_token,
+ budget_config,
http_client,
pricing: tokio::sync::OnceCell::new(),
}
}
+ async fn ensure_budget_available(&self, path: &str) -> anyhow::Result<()> {
+ if !managed_budget_applies_to_path(path) {
+ return Ok(());
+ }
+ if let Some(config) = &self.budget_config {
+ if crate::openhuman::team::managed_tool_budget_exhausted(config).await {
+ anyhow::bail!(
+ "Managed cloud tools are disabled because your OpenHuman AI credits are exhausted. Add credits or route the task to user-supplied providers."
+ );
+ }
+ }
+ Ok(())
+ }
+
/// POST JSON to a backend endpoint and parse the response `data` field.
pub async fn post(
&self,
path: &str,
body: &serde_json::Value,
) -> anyhow::Result {
+ self.ensure_budget_available(path).await?;
let url = crate::api::config::api_url(&self.backend_url, path);
tracing::debug!("[integrations] POST {}", url);
@@ -207,6 +244,7 @@ impl IntegrationClient {
/// GET from a backend endpoint and parse the response `data` field.
pub async fn get(&self, path: &str) -> anyhow::Result {
+ self.ensure_budget_available(path).await?;
let url = crate::api::config::api_url(&self.backend_url, path);
tracing::debug!("[integrations] GET {}", url);
@@ -391,7 +429,11 @@ pub fn build_client(config: &crate::openhuman::config::Config) -> Option {
tracing::warn!(
diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs
index d95f06224..be618c68b 100644
--- a/src/openhuman/integrations/client_tests.rs
+++ b/src/openhuman/integrations/client_tests.rs
@@ -87,6 +87,20 @@ fn extract_error_detail_envelope_blank_error_falls_back() {
assert_eq!(extract_error_detail(body, 500), body);
}
+#[test]
+fn managed_budget_gate_applies_to_agent_integration_paths() {
+ assert!(managed_budget_applies_to_path(
+ "/agent-integrations/composio/execute"
+ ));
+ assert!(managed_budget_applies_to_path(
+ "/agent-integrations/parallel/search"
+ ));
+ assert!(!managed_budget_applies_to_path(
+ "/agent-integrations/pricing"
+ ));
+ assert!(!managed_budget_applies_to_path("/teams/me/usage"));
+}
+
// ── Integration: HTTP error propagation through `post`/`get` ──────
async fn start_mock_backend(app: Router) -> String {
diff --git a/src/openhuman/team/ops.rs b/src/openhuman/team/ops.rs
index 8c69d36b1..62084488a 100644
--- a/src/openhuman/team/ops.rs
+++ b/src/openhuman/team/ops.rs
@@ -89,6 +89,44 @@ pub async fn get_usage(config: &Config) -> Result, String> {
))
}
+fn usage_number(data: &Value, key: &str) -> f64 {
+ data.get(key).and_then(Value::as_f64).unwrap_or(0.0)
+}
+
+/// Returns true when backend usage says OpenHuman-managed spend should stop.
+///
+/// A brand-new free account can legitimately have `remainingUsd == 0` and no
+/// recurring budget; that should not disable managed tools on its own. We only
+/// gate once there is an actual cycle budget/spend signal, matching the
+/// frontend's exhausted-budget semantics while covering spend-only payloads.
+pub fn usage_budget_exhausted(data: &Value) -> bool {
+ let remaining = usage_number(data, "remainingUsd");
+ let cycle_budget = usage_number(data, "cycleBudgetUsd");
+ let cycle_spent = usage_number(data, "cycleSpentUsd");
+ let cycle_limit_7day = usage_number(data, "cycleLimit7day");
+ let bypass = data
+ .get("bypassCycleLimit")
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+
+ !bypass
+ && remaining <= 0.01
+ && (cycle_budget > 0.01 || cycle_spent > 0.01 || cycle_limit_7day > 0.01)
+}
+
+pub async fn managed_tool_budget_exhausted(config: &Config) -> bool {
+ match get_usage(config).await {
+ Ok(outcome) => usage_budget_exhausted(&outcome.value),
+ Err(err) => {
+ tracing::debug!(
+ error = %err,
+ "[budget-gate] usage probe failed; allowing managed tool to defer to backend"
+ );
+ false
+ }
+ }
+}
+
pub async fn list_members(config: &Config, team_id: &str) -> Result, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id, "members"])?;
@@ -331,6 +369,38 @@ mod tests {
assert_eq!(normalize_id("a b", "x").unwrap(), "a b");
}
+ #[test]
+ fn usage_budget_exhausted_requires_real_cycle_signal() {
+ assert!(!usage_budget_exhausted(&json!({
+ "remainingUsd": 0,
+ "cycleBudgetUsd": 0,
+ "cycleSpentUsd": 0,
+ })));
+ assert!(usage_budget_exhausted(&json!({
+ "remainingUsd": 0,
+ "cycleBudgetUsd": 10,
+ "cycleSpentUsd": 10,
+ })));
+ assert!(usage_budget_exhausted(&json!({
+ "remainingUsd": 0,
+ "cycleBudgetUsd": 0,
+ "cycleSpentUsd": 2,
+ })));
+ }
+
+ #[test]
+ fn usage_budget_exhausted_honors_remaining_and_bypass() {
+ assert!(!usage_budget_exhausted(&json!({
+ "remainingUsd": 0.25,
+ "cycleBudgetUsd": 10,
+ })));
+ assert!(!usage_budget_exhausted(&json!({
+ "remainingUsd": 0,
+ "cycleBudgetUsd": 10,
+ "bypassCycleLimit": true,
+ })));
+ }
+
// --- pre-HTTP input validation (no network) -----------------------------
fn cfg() -> Config {