Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/server/services/Schedules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function getService() {
getAppConfig,
findUserById: (userId) =>
mongoose.models.User.findById(userId).select('_id tenantId role').lean(),
findBalance: (userId) => methods.findBalanceByUser(userId, { includeReservedCredits: true }),
findBalance: (userId, options) =>
methods.findBalanceByUser(userId, { includeReservedCredits: true, ...options }),
upsertBalance: (userId, { set, setOnInsert }) =>
methods.upsertBalanceFields(userId, set ?? {}, setOnInsert ?? {}),
// Compare-and-set: only initialize an existing record while its credit is still null.
Expand Down
18 changes: 15 additions & 3 deletions client/src/components/Balance/Summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { useTranslation } from 'react-i18next';
import { Progress, Skeleton } from '@librechat/client';
import type { BalanceSummary, BalanceTone, CurrencyConfig } from '~/utils';
import type { BalanceState } from '~/hooks/useBalanceSummary';
import { formatBalanceAmount, formatTimeUntil, cn } from '~/utils';
import { formatBalanceAmount, formatTimeUntil } from '~/utils/balance';
import useTimeTick from '~/hooks/useTimeTick';
import { useLocalize } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
import cn from '~/utils/cn';

const TONE_VARIANT = {
normal: 'default',
Expand All @@ -31,12 +32,23 @@ function useRefillText(summary: BalanceSummary, currency?: CurrencyConfig): stri
/** Re-render once a minute so "in 5 minutes" counts down while the view stays open */
useTimeTick();

const { display, refillAmount, nextRefill } = summary;
const { display, refillAmount, refillMode, nextRefill } = summary;
if (refillAmount == null) {
return localize('com_ui_balance_no_refill');
}
const when =
nextRefill != null ? formatTimeUntil(nextRefill, Date.now(), i18n.resolvedLanguage) : null;
if (refillMode === 'reset') {
if (display === 'percent') {
return when != null
? localize('com_ui_balance_reset_in', { 0: when })
: localize('com_ui_balance_reset_due');
}
const amount = formatBalanceAmount(refillAmount, display, currency);
return when != null
? localize('com_ui_balance_reset_amount_in', { 0: amount, 1: when })
: localize('com_ui_balance_reset_amount_due', { 0: amount });
}
if (display === 'percent') {
return when != null
? localize('com_ui_balance_refill_in', { 0: when })
Expand Down
27 changes: 26 additions & 1 deletion client/src/components/Balance/__tests__/Summary.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import type { TBalanceResponse } from 'librechat-data-provider';
import type { BalanceState } from '~/hooks/useBalanceSummary';
import { summarizeBalance } from '~/utils';
import { summarizeBalance } from '~/utils/balance';
import Summary from '../Summary';

const NOW = Date.parse('2026-07-05T00:00:00.000Z');
Expand Down Expand Up @@ -64,6 +64,31 @@ describe('balance Summary', () => {
expect(screen.getByTestId('balance-refill')).toHaveTextContent('Refills when you run out');
});

it('shows reset timing without exposing the allowance in percent mode', () => {
const { container } = render(<Summary state={success('percent', { refillMode: 'reset' })} />);
expect(screen.getByTestId('balance-refill')).toHaveTextContent('Usage resets in 3 days');
expect(container).not.toHaveTextContent(/credits|5,000,000|3,100,000|\$/);
});

it('shows a due reset on the next request rather than waiting for exhaustion', () => {
render(
<Summary
state={success('percent', {
refillMode: 'reset',
lastRefill: '2026-06-01T00:00:00.000Z',
})}
/>,
);
expect(screen.getByTestId('balance-refill')).toHaveTextContent(
'Usage resets on your next request',
);
});

it('shows the reset target in currency mode', () => {
render(<Summary state={success('currency', { refillMode: 'reset' })} />);
expect(screen.getByTestId('balance-refill')).toHaveTextContent('Resets to $5.00 in 3 days');
});

it('flags an empty balance', () => {
render(<Summary state={success('credits', { tokenCredits: 0 })} />);
expect(screen.getByTestId('balance-status')).toHaveTextContent('Out of credits');
Expand Down
1 change: 1 addition & 0 deletions client/src/components/Nav/Settings/BillingControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function AutoRefill() {

return (
<AutoRefillSettings
refillMode={summary.refillMode}
lastRefill={lastRefill}
nextRefill={summary.nextRefill}
/** Percent-only deployments show no credit figures anywhere */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react';
import { Label, InfoHoverCard, ESide } from '@librechat/client';

import type { RefillIntervalUnit, TBalanceResponse } from 'librechat-data-provider';
import type {
BalanceRefillMode,
RefillIntervalUnit,
TBalanceResponse,
} from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';

import { useLocalize, useClockFormat } from '~/hooks';
Expand All @@ -11,6 +15,7 @@ function ensureExhaustive(value: never): void {
}

interface AutoRefillSettingsProps {
refillMode?: BalanceRefillMode;
lastRefill: NonNullable<TBalanceResponse['lastRefill']>;
/** When the next refill becomes eligible — the same date the balance summary counts down to */
nextRefill: Date | null;
Expand All @@ -21,6 +26,7 @@ interface AutoRefillSettingsProps {
}

const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
refillMode = 'add',
lastRefill,
nextRefill,
refillAmount,
Expand All @@ -29,6 +35,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
}) => {
const localize = useLocalize();
const hour12 = useClockFormat();
const reset = refillMode === 'reset';

const lastRefillDate = lastRefill ? new Date(lastRefill) : null;

Expand Down Expand Up @@ -63,14 +70,22 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({

return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{localize('com_nav_balance_auto_refill_settings')}</h3>
<h3 className="text-lg font-medium">
{localize(
reset ? 'com_nav_balance_auto_reset_settings' : 'com_nav_balance_auto_refill_settings',
)}
</h3>
<div className="mb-1 flex justify-between text-sm">
<span>{localize('com_nav_balance_last_refill')}</span>
<span>
{localize(reset ? 'com_nav_balance_last_reset' : 'com_nav_balance_last_refill')}
</span>
<span>{lastRefillDate ? lastRefillDate.toLocaleString(undefined, { hour12 }) : '-'}</span>
</div>
{refillAmount != null && (
<div className="mb-1 flex justify-between text-sm">
<span>{localize('com_nav_balance_refill_amount')}</span>
<span>
{localize(reset ? 'com_nav_balance_reset_amount' : 'com_nav_balance_refill_amount')}
</span>
<span>{refillAmount}</span>
</div>
)}
Expand All @@ -83,8 +98,15 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_balance_next_refill_info')} />
<Label className="font-light">
{localize(reset ? 'com_nav_balance_next_reset' : 'com_nav_balance_next_refill')}
</Label>
<InfoHoverCard
side={ESide.Bottom}
text={localize(
reset ? 'com_nav_balance_next_reset_info' : 'com_nav_balance_next_refill_info',
)}
/>
</div>

<span className="text-text-primary text-sm font-medium" role="note">
Expand Down
9 changes: 9 additions & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@
"com_ui_balance_no_refill": "Doesn't refill automatically",
"com_ui_balance_refill_amount_in": "+{{0}} available {{1}}",
"com_ui_balance_refill_amount_when_empty": "+{{0}} when you run out",
"com_ui_balance_reset_in": "Usage resets {{0}}",
"com_ui_balance_reset_due": "Usage resets on your next request",
"com_ui_balance_reset_amount_in": "Resets to {{0}} {{1}}",
"com_ui_balance_reset_amount_due": "Resets to {{0}} on your next request",
"com_nav_balance_auto_reset_settings": "Automatic reset settings",
"com_nav_balance_last_reset": "Last reset",
"com_nav_balance_reset_amount": "Reset allowance",
"com_nav_balance_next_reset": "Next reset",
"com_nav_balance_next_reset_info": "Once the interval has passed, reading your balance or sending a request replaces it with the configured allowance. Unused credits do not carry over. In-flight requests still count against the allowance.",
"com_ui_balance_refill_in": "Refill available {{0}}",
"com_ui_balance_refill_when_empty": "Refills when you run out",
"com_ui_balance_usage_limit": "Usage limit",
Expand Down
4 changes: 3 additions & 1 deletion client/src/utils/balance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CREDITS_PER_USD, getRefillEligibilityDate } from 'librechat-data-provider';
import type { BalanceDisplay, TBalanceResponse } from 'librechat-data-provider';
import type { BalanceDisplay, BalanceRefillMode, TBalanceResponse } from 'librechat-data-provider';
import type { CurrencyConfig } from './tokens';
import { formatCost } from './tokens';

Expand All @@ -23,6 +23,7 @@ export interface BalanceSummary {
tone: BalanceTone;
/** Credits the next auto-refill adds; null without auto-refill. */
refillAmount: number | null;
refillMode: BalanceRefillMode;
/** When the next auto-refill becomes eligible; null without auto-refill. */
nextRefill: Date | null;
}
Expand Down Expand Up @@ -86,6 +87,7 @@ export function summarizeBalance(
usedPercent,
tone,
refillAmount,
refillMode: balance.refillMode ?? 'add',
nextRefill: balance.autoRefillEnabled ? getNextRefill(balance) : null,
};
}
Expand Down
4 changes: 3 additions & 1 deletion librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,11 @@ registration:
# enabled: false
# startBalance: 20000
# autoRefillEnabled: false
# refillIntervalValue: 30
# refillIntervalValue: 30 # Reset mode requires a positive integer
# refillIntervalUnit: 'days'
# refillAmount: 10000
# # Deploy reset-capable servers to every replica before selecting 'reset'.
# refillMode: 'add' # 'reset' replaces the allowance each period without rollover
# reservationTtlMs: 1800000 # How long an in-flight request's reserved credits count if never released
# display: 'credits' # credits | currency (uses interface.currency) | percent (share of the allotment used)

Expand Down
55 changes: 54 additions & 1 deletion packages/api/src/middleware/balance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MongoMemoryServer } from 'mongodb-memory-server';
import { logger, balanceSchema } from '@librechat/data-schemas';
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
import type { IBalance, IBalanceUpdate } from '@librechat/data-schemas';
import { createSetBalanceConfig } from './balance';
import { buildBalanceUpdateFields, createSetBalanceConfig } from './balance';

jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
Expand Down Expand Up @@ -381,6 +381,41 @@ describe('createSetBalanceConfig', () => {
// This should have fixed the issue - user should no longer get the error
});

test('disables a due stored reset before balance reads and admission', async () => {
const userId = new mongoose.Types.ObjectId();
const lastRefill = new Date('2020-01-01');
await Balance.create({
user: userId,
tokenCredits: 500,
autoRefillEnabled: true,
refillMode: 'reset',
refillAmount: 1000,
refillIntervalValue: 1,
refillIntervalUnit: 'weeks',
lastRefill,
});
const read = jest.fn(findBalanceByUser);
const middleware = createSetBalanceConfig({
getAppConfig: jest.fn().mockResolvedValue({
balance: { enabled: true, startBalance: 1000, autoRefillEnabled: false },
}),
findBalanceByUser: read,
upsertBalanceFields,
});
await middleware(
createMockRequest(userId) as ServerRequest,
createMockResponse() as ServerResponse,
mockNext,
);
expect(read).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledWith(userId.toString(), { applyReset: false });
expect(await findBalanceByUser(userId.toString())).toMatchObject({
tokenCredits: 500,
autoRefillEnabled: false,
lastRefill,
});
});

test('should not set lastRefill when auto-refill is disabled', async () => {
const userId = new mongoose.Types.ObjectId();

Expand Down Expand Up @@ -731,3 +766,21 @@ describe('createSetBalanceConfig', () => {
);
});
});

describe('balance refill mode synchronization', () => {
const config = {
autoRefillEnabled: true,
refillAmount: 1000,
refillIntervalValue: 1,
refillIntervalUnit: 'weeks' as const,
};
test('persists reset mode for existing balances', () => {
expect(
buildBalanceUpdateFields({ ...config, refillMode: 'reset' }, null, 'user-1'),
).toMatchObject({ refillMode: 'reset' });
});
test('restores additive mode when the reset option is removed', () => {
const record = { ...config, refillMode: 'reset', lastRefill: new Date() } as IBalance;
expect(buildBalanceUpdateFields(config, record, 'user-1')).toEqual({ refillMode: 'add' });
});
});
27 changes: 23 additions & 4 deletions packages/api/src/middleware/balance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from '@librechat/data-schemas';
import { isBalanceRefillDue } from 'librechat-data-provider';
import type {
IBalanceUpdate,
BalanceConfig,
Expand All @@ -18,7 +19,10 @@ export interface BalanceMiddlewareOptions {
tenantId?: string;
refresh?: boolean;
}) => Promise<AppConfig>;
findBalanceByUser: (userId: string) => Promise<IBalance | null>;
findBalanceByUser: (
userId: string,
options?: { applyReset?: boolean },
) => Promise<IBalance | null>;
upsertBalanceFields: (
userId: string,
fields: IBalanceUpdate,
Expand Down Expand Up @@ -80,9 +84,14 @@ export function buildBalanceUpdateFields(
config.autoRefillEnabled &&
config.refillIntervalValue != null &&
config.refillIntervalUnit != null &&
config.refillAmount != null;
config.refillAmount != null &&
(config.refillMode !== 'reset' ||
(Number.isInteger(config.refillIntervalValue) && config.refillIntervalValue > 0));

if (!isAutoRefillConfigValid) {
if (userRecord?.autoRefillEnabled === true) {
updateFields.autoRefillEnabled = false;
}
return updateFields;
}

Expand All @@ -98,6 +107,11 @@ export function buildBalanceUpdateFields(
updateFields.refillIntervalUnit = config.refillIntervalUnit;
}

const refillMode = config.refillMode ?? 'add';
if ((userRecord?.refillMode ?? 'add') !== refillMode) {
updateFields.refillMode = refillMode;
}

if (userRecord?.refillAmount !== config.refillAmount) {
updateFields.refillAmount = config.refillAmount;
}
Expand Down Expand Up @@ -147,11 +161,16 @@ export function createSetBalanceConfig({
}
const userId = typeof user._id === 'string' ? user._id : user._id.toString();
await runBalanceUpdate(userId, async () => {
const userBalanceRecord = await findBalanceByUser(userId);
const userBalanceRecord = await findBalanceByUser(userId, { applyReset: false });
const updateFields = buildBalanceUpdateFields(balanceConfig, userBalanceRecord, userId);

if (Object.keys(updateFields).length === 0) {
balanceLocals.balanceData = userBalanceRecord;
balanceLocals.balanceData =
balanceConfig.autoRefillEnabled === true &&
userBalanceRecord?.refillMode === 'reset' &&
isBalanceRefillDue(userBalanceRecord, new Date())
? await findBalanceByUser(userId)
Comment thread
danny-avila marked this conversation as resolved.
: userBalanceRecord;
return;
}

Expand Down
Loading
Loading