diff --git a/app/src/components/home/HomeBanners.tsx b/app/src/components/home/HomeBanners.tsx index 02cd23234..584b7093d 100644 --- a/app/src/components/home/HomeBanners.tsx +++ b/app/src/components/home/HomeBanners.tsx @@ -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} + {secondaryCtaLabel && onSecondaryCtaClick && ( + <> + {' '} + + + )}

diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a72de77a9..31bf9a290 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'روتيناتك', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4ed95143d..e7f95a789 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'তোমার বিদ্বান', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 54a95a0aa..17e3007e1 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a81f3330d..d94d5aef1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0c25df57d..d95078847 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 30144596b..15f48ad92 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -209,6 +209,10 @@ const messages: TranslationMap = { 'home.usageExhaustedBody': 'Vous n’avez plus d’utilisation 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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index c4eeba170..24a075758 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'आपका दिनचर्या', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 63b41a8eb..6eff6e3c6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 6c2a57d30..adb62c3f2 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -207,6 +207,10 @@ const messages: TranslationMap = { 'home.usageExhaustedBody': 'Hai esaurito per ora l’utilizzo 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 l’accesso a OpenRouter e riprova.', 'home.routinesCard': 'Le tue routine', 'home.routinesActive': '{count} attive', 'routines.title': 'Le tue routine', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 19f14a498..5ab29ea98 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '내 루틴', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 5628f9800..f7102666e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3a75512c7..d00f95440 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index ca4c06e59..d497fde9c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Ваши рутины', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 65d202f23..a45e92520 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '你的例程', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 2fbf528c6..702fbe943 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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(null); const [attachError, setAttachError] = useState(null); const [sendAdvisory, setSendAdvisory] = useState(null); + const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const [pendingSendingThreadId, setPendingSendingThreadId] = useState(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 = ({ )} {teamUsage && shouldShowBudgetCompletedMessage && ( -
+
-

+

{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 {