mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Suggest OpenRouter free models when credits run out (#3320)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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': 'روتيناتك',
|
||||
|
||||
@@ -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': 'তোমার বিদ্বান',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'आपका दिनचर्या',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '내 루틴',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Ваши рутины',
|
||||
|
||||
@@ -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': '你的例程',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(() => {}));
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user