Suggest OpenRouter free models when credits run out (#3320)

This commit is contained in:
Steven Enamakel
2026-06-03 22:24:33 -04:00
committed by GitHub
parent 0fa0c3a1a0
commit 4c69aa6e09
27 changed files with 604 additions and 13 deletions
+15
View File
@@ -12,12 +12,16 @@ export function UsageLimitBanner({
title,
message,
ctaLabel,
secondaryCtaLabel,
onSecondaryCtaClick,
}: {
tone: 'warning' | 'danger';
icon: string;
title: string;
message: string;
ctaLabel: string;
secondaryCtaLabel?: string;
onSecondaryCtaClick?: () => void;
}) {
const styles =
tone === 'danger'
@@ -54,6 +58,17 @@ export function UsageLimitBanner({
className={`cursor-pointer border-b border-dashed font-bold ${styles.button}`}>
{ctaLabel}
</button>
{secondaryCtaLabel && onSecondaryCtaClick && (
<>
{' '}
<button
type="button"
onClick={onSecondaryCtaClick}
className={`cursor-pointer border-b border-dashed font-bold ${styles.button}`}>
{secondaryCtaLabel}
</button>
</>
)}
</p>
</div>
</div>
+4
View File
@@ -199,6 +199,10 @@ const messages: TranslationMap = {
'home.usageExhaustedTitle': 'لقد استنفدت حصتك',
'home.usageExhaustedBody': 'لقد نفدت حصتك المضمنة حالياً. ابدأ اشتراكاً لفتح سعة إضافية مستمرة.',
'home.usageExhaustedCta': 'ابدأ الاشتراك',
'openrouterFree.cta': 'استخدم نماذج OpenRouter المجانية',
'openrouterFree.saving': 'جارٍ الاتصال بـ OpenRouter...',
'openrouterFree.error':
'تعذر التبديل إلى نماذج OpenRouter المجانية. تحقق من تسجيل الدخول إلى OpenRouter وحاول مرة أخرى.',
'home.routinesCard': 'روتيناتك',
'home.routinesActive': '{count} نشطة',
'routines.title': 'روتيناتك',
+4
View File
@@ -201,6 +201,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'আপাতত আপনার অন্তর্ভুক্ত ব্যবহার শেষ। আরও ধারাবাহিক সক্ষমতা আনলক করতে একটি সাবস্ক্রিপশন শুরু করুন।',
'home.usageExhaustedCta': 'সাবস্ক্রিপশন শুরু করুন',
'openrouterFree.cta': 'OpenRouter-এর বিনামূল্যের মডেল ব্যবহার করুন',
'openrouterFree.saving': 'OpenRouter সংযুক্ত হচ্ছে...',
'openrouterFree.error':
'OpenRouter-এর বিনামূল্যের মডেলে স্যুইচ করা যায়নি। আপনার OpenRouter সাইন-ইন পরীক্ষা করে আবার চেষ্টা করুন।',
'home.routinesCard': 'আপনার রুটিন',
'home.routinesActive': '{count}টি সক্রিয়',
'routines.title': 'তোমার বিদ্বান',
+4
View File
@@ -210,6 +210,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Dein enthaltenes Nutzungskontingent ist vorerst aufgebraucht. Starte ein Abo, um mehr laufende Kapazität freizuschalten.',
'home.usageExhaustedCta': 'Abo starten',
'openrouterFree.cta': 'Kostenlose OpenRouter-Modelle verwenden',
'openrouterFree.saving': 'OpenRouter wird verbunden...',
'openrouterFree.error':
'Der Wechsel zu kostenlosen OpenRouter-Modellen ist fehlgeschlagen. Prüfe deine OpenRouter-Anmeldung und versuche es erneut.',
'home.routinesCard': 'Deine Routinen',
'home.routinesActive': '{count} aktiv',
'routines.title': 'Ihre Routinen',
+4
View File
@@ -220,6 +220,10 @@ const en: TranslationMap = {
'home.usageExhaustedBody':
"You're out of included usage for now. Start a subscription to unlock more ongoing capacity.",
'home.usageExhaustedCta': 'Get a subscription',
'openrouterFree.cta': 'Use OpenRouter free models',
'openrouterFree.saving': 'Connecting OpenRouter...',
'openrouterFree.error':
'Could not switch to OpenRouter free models. Check your OpenRouter sign-in and try again.',
'home.routinesCard': 'Your Routines',
'home.routinesActive': '{count} active',
+4
View File
@@ -211,6 +211,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Ya no te queda uso incluido por ahora. Inicia una suscripción para desbloquear más capacidad continua.',
'home.usageExhaustedCta': 'Obtener una suscripción',
'openrouterFree.cta': 'Usar modelos gratis de OpenRouter',
'openrouterFree.saving': 'Conectando OpenRouter...',
'openrouterFree.error':
'No se pudo cambiar a los modelos gratis de OpenRouter. Revisa tu inicio de sesión de OpenRouter e inténtalo de nuevo.',
'home.routinesCard': 'Tus rutinas',
'home.routinesActive': '{count} activas',
'routines.title': 'Tus rutinas',
+4
View File
@@ -209,6 +209,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Vous navez plus dutilisation incluse pour le moment. Démarrez un abonnement pour débloquer davantage de capacité continue.',
'home.usageExhaustedCta': 'Prendre un abonnement',
'openrouterFree.cta': 'Utiliser les modèles gratuits OpenRouter',
'openrouterFree.saving': 'Connexion à OpenRouter...',
'openrouterFree.error':
'Impossible de passer aux modèles gratuits OpenRouter. Vérifiez votre connexion OpenRouter et réessayez.',
'home.routinesCard': 'Vos routines',
'home.routinesActive': '{count} actif(s)',
'routines.title': 'Vos Routines',
+4
View File
@@ -201,6 +201,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'आपकी शामिल उपयोग सीमा अभी समाप्त हो चुकी है। अधिक निरंतर क्षमता अनलॉक करने के लिए सदस्यता शुरू करें।',
'home.usageExhaustedCta': 'सदस्यता शुरू करें',
'openrouterFree.cta': 'OpenRouter के मुफ्त मॉडल इस्तेमाल करें',
'openrouterFree.saving': 'OpenRouter कनेक्ट हो रहा है...',
'openrouterFree.error':
'OpenRouter के मुफ्त मॉडल पर स्विच नहीं हो सका। अपना OpenRouter साइन-इन जांचें और फिर कोशिश करें।',
'home.routinesCard': 'आपकी रूटीन',
'home.routinesActive': '{count} सक्रिय',
'routines.title': 'आपका दिनचर्या',
+4
View File
@@ -203,6 +203,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Kuota penggunaan yang termasuk saat ini sudah habis. Mulai langganan untuk membuka kapasitas berkelanjutan yang lebih besar.',
'home.usageExhaustedCta': 'Mulai berlangganan',
'openrouterFree.cta': 'Gunakan model gratis OpenRouter',
'openrouterFree.saving': 'Menghubungkan OpenRouter...',
'openrouterFree.error':
'Tidak dapat beralih ke model gratis OpenRouter. Periksa login OpenRouter Anda dan coba lagi.',
'home.routinesCard': 'Rutinitas Anda',
'home.routinesActive': '{count} aktif',
'routines.title': 'Rutinitas Anda',
+4
View File
@@ -207,6 +207,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Hai esaurito per ora lutilizzo incluso. Avvia un abbonamento per sbloccare più capacità continuativa.',
'home.usageExhaustedCta': 'Attiva un abbonamento',
'openrouterFree.cta': 'Usa i modelli gratuiti di OpenRouter',
'openrouterFree.saving': 'Connessione a OpenRouter...',
'openrouterFree.error':
'Impossibile passare ai modelli gratuiti di OpenRouter. Controlla laccesso a OpenRouter e riprova.',
'home.routinesCard': 'Le tue routine',
'home.routinesActive': '{count} attive',
'routines.title': 'Le tue routine',
+4
View File
@@ -201,6 +201,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'현재 포함된 사용량을 모두 소진했습니다. 더 많은 지속 용량을 사용하려면 구독을 시작하세요.',
'home.usageExhaustedCta': '구독 시작',
'openrouterFree.cta': 'OpenRouter 무료 모델 사용',
'openrouterFree.saving': 'OpenRouter 연결 중...',
'openrouterFree.error':
'OpenRouter 무료 모델로 전환하지 못했습니다. OpenRouter 로그인을 확인하고 다시 시도하세요.',
'home.routinesCard': '내 루틴',
'home.routinesActive': '{count}개 활성',
'routines.title': '내 루틴',
+4
View File
@@ -205,6 +205,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Twój limit użycia na ten moment został wyczerpany. Wykup subskrypcję, aby odblokować większą ciągłą pojemność.',
'home.usageExhaustedCta': 'Wykup subskrypcję',
'openrouterFree.cta': 'Użyj darmowych modeli OpenRouter',
'openrouterFree.saving': 'Łączenie z OpenRouter...',
'openrouterFree.error':
'Nie udało się przełączyć na darmowe modele OpenRouter. Sprawdź logowanie do OpenRouter i spróbuj ponownie.',
'home.routinesCard': 'Twoje Rutyny',
'home.routinesActive': '{count} aktywnych',
'routines.title': 'Twoje rutyny',
+4
View File
@@ -209,6 +209,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Seu uso incluído acabou por enquanto. Inicie uma assinatura para desbloquear mais capacidade contínua.',
'home.usageExhaustedCta': 'Assinar',
'openrouterFree.cta': 'Usar modelos grátis da OpenRouter',
'openrouterFree.saving': 'Conectando à OpenRouter...',
'openrouterFree.error':
'Não foi possível mudar para os modelos grátis da OpenRouter. Verifique seu login da OpenRouter e tente novamente.',
'home.routinesCard': 'Suas Rotinas',
'home.routinesActive': '{count} ativas',
'routines.title': 'Seus Hábitos',
+4
View File
@@ -203,6 +203,10 @@ const messages: TranslationMap = {
'home.usageExhaustedBody':
'Включённый объём использования пока исчерпан. Оформите подписку, чтобы получить больше постоянной мощности.',
'home.usageExhaustedCta': 'Оформить подписку',
'openrouterFree.cta': 'Использовать бесплатные модели OpenRouter',
'openrouterFree.saving': 'Подключение OpenRouter...',
'openrouterFree.error':
'Не удалось переключиться на бесплатные модели OpenRouter. Проверьте вход в OpenRouter и повторите попытку.',
'home.routinesCard': 'Ваши подпрограммы',
'home.routinesActive': '{count} активных',
'routines.title': 'Ваши рутины',
+3
View File
@@ -192,6 +192,9 @@ const messages: TranslationMap = {
'home.usageExhaustedTitle': '您的使用额度已耗尽',
'home.usageExhaustedBody': '您当前已用完包含的使用额度。开始订阅以解锁更多持续容量。',
'home.usageExhaustedCta': '开始订阅',
'openrouterFree.cta': '使用 OpenRouter 免费模型',
'openrouterFree.saving': '正在连接 OpenRouter...',
'openrouterFree.error': '无法切换到 OpenRouter 免费模型。请检查 OpenRouter 登录后重试。',
'home.routinesCard': '我的例程',
'home.routinesActive': '{count} 个运行中',
'routines.title': '你的例程',
+43 -11
View File
@@ -28,6 +28,7 @@ import {
} from '../lib/attachments';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels';
import { threadApi } from '../services/api/threadApi';
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
import { store } from '../store';
@@ -222,6 +223,7 @@ const Conversations = ({
const [sendError, setSendError] = useState<ChatSendError | null>(null);
const [attachError, setAttachError] = useState<ChatSendError | null>(null);
const [sendAdvisory, setSendAdvisory] = useState<string | null>(null);
const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [pendingSendingThreadId, setPendingSendingThreadId] = useState<string | null>(null);
const [profileDraftOpen, setProfileDraftOpen] = useState(false);
const [profileDraft, setProfileDraft] = useState(DEFAULT_PROFILE_DRAFT);
@@ -334,6 +336,17 @@ const Conversations = ({
void dispatch(loadThreadMessages(thread.id));
};
const handleUseOpenRouterFree = async () => {
setOpenRouterStatus('saving');
try {
await applyOpenRouterFreeModels();
setOpenRouterStatus('idle');
} catch (err) {
console.warn('[chat] applyOpenRouterFreeModels failed', err);
setOpenRouterStatus('error');
}
};
const handleStartEditTitle = () => {
if (!selectedThreadId) return;
const thr = threads.find(t => t.id === selectedThreadId);
@@ -2040,7 +2053,7 @@ const Conversations = ({
</div>
)}
{teamUsage && shouldShowBudgetCompletedMessage && (
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex items-center justify-between gap-3">
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<svg
className="w-4 h-4 text-coral-400 flex-shrink-0"
@@ -2054,21 +2067,40 @@ const Conversations = ({
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-xs text-coral-600 truncate">
<p className="text-xs text-coral-600">
{teamUsage.cycleBudgetUsd > 0
? `${t('chat.weeklyLimitHit')}${teamUsage.cycleEndsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} ${t('chat.topUpToContinue')}`
: t('chat.budgetComplete')}
</p>
</div>
<button
type="button"
data-analytics-id="chat-budget-top-up"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
{t('chat.topUp')}
</button>
<div className="flex flex-shrink-0 items-center gap-2">
<button
type="button"
data-analytics-id="chat-budget-openrouter-free"
disabled={openRouterStatus === 'saving'}
onClick={() => {
void handleUseOpenRouterFree();
}}
className="px-3 py-1.5 rounded-lg border border-coral-300 bg-white text-coral-700 hover:bg-coral-100 disabled:cursor-wait disabled:opacity-70 text-xs font-medium transition-colors">
{openRouterStatus === 'saving'
? t('openrouterFree.saving')
: t('openrouterFree.cta')}
</button>
<button
type="button"
data-analytics-id="chat-budget-top-up"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
{t('chat.topUp')}
</button>
</div>
</div>
)}
{openRouterStatus === 'error' && (
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
{t('openrouterFree.error')}
</div>
)}
+27
View File
@@ -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<string | null>(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' && (
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-900/20 dark:text-coral-200">
{t('openrouterFree.error')}
</div>
)}
{showPromoBanner && <PromotionalCreditsBanner promoCredits={promoCredits} />}
@@ -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(() => {}));
+20
View File
@@ -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(<Home />);
fireEvent.click(screen.getByRole('button', { name: /Use OpenRouter free models/i }));
await waitFor(() => {
expect(mockUseOpenRouterFreeModels).toHaveBeenCalledTimes(1);
});
});
});
@@ -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<typeof import('../aiSettingsApi')>('../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,
}),
],
})
);
});
});
@@ -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<void> {
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<Record<string, ProviderRef>>((acc, workload) => {
acc[workload] = { kind: 'cloud', providerSlug: 'openrouter', model: OPENROUTER_FREE_MODEL };
return acc;
}, {}),
},
};
await saveAISettings(current, next);
requestUsageRefresh();
}
@@ -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 {
@@ -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))
}
}
@@ -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(
+43 -1
View File
@@ -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<Arc<crate::openhuman::config::Config>>,
http_client: reqwest::Client,
pricing: tokio::sync::OnceCell<IntegrationPricing>,
}
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<crate::openhuman::config::Config>,
) -> Self {
Self::new_inner(backend_url, auth_token, Some(config))
}
fn new_inner(
backend_url: String,
auth_token: String,
budget_config: Option<Arc<crate::openhuman::config::Config>>,
) -> 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<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &serde_json::Value,
) -> anyhow::Result<T> {
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<T: serde::de::DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
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<Arc<Int
backend_url = %backend_url,
"[integrations] client built (session token resolved)"
);
Some(Arc::new(IntegrationClient::new(backend_url, token)))
Some(Arc::new(IntegrationClient::new_with_budget_config(
backend_url,
token,
Arc::new(config.clone()),
)))
}
None => {
tracing::warn!(
@@ -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 {
+70
View File
@@ -89,6 +89,44 @@ pub async fn get_usage(config: &Config) -> Result<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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 {