From e95e0052da4d59fe28ff5a0f43a8582401a55c1b Mon Sep 17 00:00:00 2001 From: Sebastien Bruel <93573440+sbruel@users.noreply.github.com> Date: Sat, 6 Sep 2025 00:21:02 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=84=EF=B8=8F=20feat:=20Allow=20Skippin?= =?UTF-8?q?g=20Transactions=20When=20Balance=20is=20Disabled=20(#9419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Disable transaction creation when balance is disabled * Add configuration to disable transactions creation * chore: remove comments --------- Co-authored-by: Danny Avila --- api/models/Transaction.js | 12 +- api/models/Transaction.spec.js | 190 ++++++++++++- api/server/controllers/agents/client.js | 13 +- api/server/controllers/agents/client.test.js | 3 + api/server/services/AppService.js | 2 + librechat.example.yaml | 8 + packages/api/src/app/config.test.ts | 285 +++++++++++++++++++ packages/api/src/app/config.ts | 29 +- packages/api/src/types/config.ts | 2 + packages/data-provider/src/config.ts | 7 + 10 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/app/config.test.ts diff --git a/api/models/Transaction.js b/api/models/Transaction.js index e1cff15c3..5fa20f1dd 100644 --- a/api/models/Transaction.js +++ b/api/models/Transaction.js @@ -189,11 +189,15 @@ async function createAutoRefillTransaction(txData) { * @param {txData} _txData - Transaction data. */ async function createTransaction(_txData) { - const { balance, ...txData } = _txData; + const { balance, transactions, ...txData } = _txData; if (txData.rawAmount != null && isNaN(txData.rawAmount)) { return; } + if (transactions?.enabled === false) { + return; + } + const transaction = new Transaction(txData); transaction.endpointTokenConfig = txData.endpointTokenConfig; calculateTokenValue(transaction); @@ -222,7 +226,11 @@ async function createTransaction(_txData) { * @param {txData} _txData - Transaction data. */ async function createStructuredTransaction(_txData) { - const { balance, ...txData } = _txData; + const { balance, transactions, ...txData } = _txData; + if (transactions?.enabled === false) { + return; + } + const transaction = new Transaction({ ...txData, endpointTokenConfig: txData.endpointTokenConfig, diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js index 891d9ca7d..2df9fc67f 100644 --- a/api/models/Transaction.spec.js +++ b/api/models/Transaction.spec.js @@ -1,10 +1,9 @@ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { spendTokens, spendStructuredTokens } = require('./spendTokens'); - const { getMultiplier, getCacheMultiplier } = require('./tx'); -const { createTransaction } = require('./Transaction'); -const { Balance } = require('~/db/models'); +const { createTransaction, createStructuredTransaction } = require('./Transaction'); +const { Balance, Transaction } = require('~/db/models'); let mongoServer; beforeAll(async () => { @@ -380,3 +379,188 @@ describe('NaN Handling Tests', () => { expect(balance.tokenCredits).toBe(initialBalance); }); }); + +describe('Transactions Config Tests', () => { + test('createTransaction should not save when transactions.enabled is false', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'test', + endpointTokenConfig: null, + rawAmount: -100, + tokenType: 'prompt', + transactions: { enabled: false }, + }; + + // Act + const result = await createTransaction(txData); + + // Assert: No transaction should be created + expect(result).toBeUndefined(); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(0); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('createTransaction should save when transactions.enabled is true', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'test', + endpointTokenConfig: null, + rawAmount: -100, + tokenType: 'prompt', + transactions: { enabled: true }, + balance: { enabled: true }, + }; + + // Act + const result = await createTransaction(txData); + + // Assert: Transaction should be created + expect(result).toBeDefined(); + expect(result.balance).toBeLessThan(initialBalance); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(1); + expect(transactions[0].rawAmount).toBe(-100); + }); + + test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'test', + endpointTokenConfig: null, + rawAmount: -100, + tokenType: 'prompt', + balance: { enabled: true }, + // No transactions config provided + }; + + // Act + const result = await createTransaction(txData); + + // Assert: Transaction should be created (backward compatibility) + expect(result).toBeDefined(); + expect(result.balance).toBeLessThan(initialBalance); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(1); + }); + + test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'test', + endpointTokenConfig: null, + rawAmount: -100, + tokenType: 'prompt', + transactions: { enabled: true }, + balance: { enabled: false }, + }; + + // Act + const result = await createTransaction(txData); + + // Assert: Transaction should be created but balance unchanged + expect(result).toBeUndefined(); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(1); + expect(transactions[0].rawAmount).toBe(-100); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('createStructuredTransaction should not save when transactions.enabled is false', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'message', + tokenType: 'prompt', + inputTokens: -10, + writeTokens: -100, + readTokens: -5, + transactions: { enabled: false }, + }; + + // Act + const result = await createStructuredTransaction(txData); + + // Assert: No transaction should be created + expect(result).toBeUndefined(); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(0); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => { + // Arrange + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const txData = { + user: userId, + conversationId: 'test-conversation-id', + model, + context: 'message', + tokenType: 'prompt', + inputTokens: -10, + writeTokens: -100, + readTokens: -5, + transactions: { enabled: true }, + balance: { enabled: false }, + }; + + // Act + const result = await createStructuredTransaction(txData); + + // Assert: Transaction should be created but balance unchanged + expect(result).toBeUndefined(); + const transactions = await Transaction.find({ user: userId }); + expect(transactions).toHaveLength(1); + expect(transactions[0].inputTokens).toBe(-10); + expect(transactions[0].writeTokens).toBe(-100); + expect(transactions[0].readTokens).toBe(-5); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); +}); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 1bf9e07bd..bd16522c5 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,6 +9,7 @@ const { checkAccess, resolveHeaders, getBalanceConfig, + getTransactionsConfig, memoryInstructions, formatContentStrings, createMemoryProcessor, @@ -623,11 +624,13 @@ class AgentClient extends BaseClient { * @param {string} [params.model] * @param {string} [params.context='message'] * @param {AppConfig['balance']} [params.balance] + * @param {AppConfig['transactions']} [params.transactions] * @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage] */ async recordCollectedUsage({ model, balance, + transactions, context = 'message', collectedUsage = this.collectedUsage, }) { @@ -653,6 +656,7 @@ class AgentClient extends BaseClient { const txMetadata = { context, balance, + transactions, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, @@ -1051,7 +1055,12 @@ class AgentClient extends BaseClient { } const balanceConfig = getBalanceConfig(appConfig); - await this.recordCollectedUsage({ context: 'message', balance: balanceConfig }); + const transactionsConfig = getTransactionsConfig(appConfig); + await this.recordCollectedUsage({ + context: 'message', + balance: balanceConfig, + transactions: transactionsConfig, + }); } catch (err) { logger.error( '[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage', @@ -1245,11 +1254,13 @@ class AgentClient extends BaseClient { }); const balanceConfig = getBalanceConfig(appConfig); + const transactionsConfig = getTransactionsConfig(appConfig); await this.recordCollectedUsage({ collectedUsage, context: 'title', model: clientOptions.model, balance: balanceConfig, + transactions: transactionsConfig, }).catch((err) => { logger.error( '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage', diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 5a42b6c3c..9b5a56474 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -237,6 +237,9 @@ describe('AgentClient - titleConvo', () => { balance: { enabled: false, }, + transactions: { + enabled: true, + }, }); }); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index eaaf47894..5c5bf186e 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -49,6 +49,7 @@ const AppService = async () => { enabled: isEnabled(process.env.CHECK_BALANCE), startBalance: startBalance ? parseInt(startBalance, 10) : undefined, }; + const transactions = config.transactions ?? configDefaults.transactions; const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType; process.env.CDN_PROVIDER = fileStrategy; @@ -84,6 +85,7 @@ const AppService = async () => { memory, speech, balance, + transactions, mcpConfig, webSearch, fileStrategy, diff --git a/librechat.example.yaml b/librechat.example.yaml index 016d8e7e9..62824f9c3 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -121,6 +121,14 @@ registration: # refillIntervalUnit: 'days' # refillAmount: 10000 +# Example Transactions settings +# Controls whether to save transaction records to the database +# Default is true (enabled) +#transactions: +# enabled: false +# Note: If balance.enabled is true, transactions will always be enabled +# regardless of this setting to ensure balance tracking works correctly + # speech: # tts: # openai: diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts new file mode 100644 index 000000000..82f8b3e8c --- /dev/null +++ b/packages/api/src/app/config.test.ts @@ -0,0 +1,285 @@ +import { getTransactionsConfig, getBalanceConfig } from './config'; +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; +import type { TCustomConfig } from 'librechat-data-provider'; + +// Helper function to create a minimal AppConfig for testing +const createTestAppConfig = (overrides: Partial = {}): AppConfig => { + const minimalConfig: TCustomConfig = { + version: '1.0.0', + cache: true, + interface: { + endpointsMenu: true, + }, + registration: { + socialLogins: [], + }, + endpoints: {}, + }; + + return { + config: minimalConfig, + paths: { + uploads: '', + imageOutput: '', + publicPath: '', + }, + fileStrategy: FileSources.local, + fileStrategies: {}, + imageOutputType: 'png', + ...overrides, + }; +}; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + warn: jest.fn(), + }, +})); + +jest.mock('~/utils', () => ({ + isEnabled: jest.fn((value) => value === 'true'), + normalizeEndpointName: jest.fn((name) => name), +})); + +describe('getTransactionsConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.CHECK_BALANCE; + delete process.env.START_BALANCE; + }); + + describe('when appConfig is not provided', () => { + it('should return default config with enabled: true', () => { + const result = getTransactionsConfig(); + expect(result).toEqual({ enabled: true }); + }); + }); + + describe('when appConfig is provided', () => { + it('should return transactions config when explicitly set to false', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + balance: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should return transactions config when explicitly set to true', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: true }, + balance: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should return default config when transactions is not defined', () => { + const appConfig = createTestAppConfig({ + balance: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + describe('balance and transactions interaction', () => { + it('should force transactions to be enabled when balance is enabled but transactions is disabled', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + balance: { enabled: true }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).toHaveBeenCalledWith( + 'Configuration warning: transactions.enabled=false is incompatible with balance.enabled=true. ' + + 'Transactions will be enabled to ensure balance tracking works correctly.', + ); + }); + + it('should not override transactions when balance is enabled and transactions is enabled', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: true }, + balance: { enabled: true }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should allow transactions to be disabled when balance is disabled', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + balance: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should use default when balance is enabled but transactions is not defined', () => { + const appConfig = createTestAppConfig({ + balance: { enabled: true }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('with environment variables for balance', () => { + it('should force transactions enabled when CHECK_BALANCE env is true and transactions is false', () => { + process.env.CHECK_BALANCE = 'true'; + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).toHaveBeenCalledWith( + 'Configuration warning: transactions.enabled=false is incompatible with balance.enabled=true. ' + + 'Transactions will be enabled to ensure balance tracking works correctly.', + ); + }); + + it('should allow transactions disabled when CHECK_BALANCE env is false', () => { + process.env.CHECK_BALANCE = 'false'; + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle empty appConfig object', () => { + const appConfig = createTestAppConfig(); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should handle appConfig with null balance', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + balance: null as any, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should handle appConfig with undefined balance', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + balance: undefined, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should handle appConfig with balance enabled undefined', () => { + const appConfig = createTestAppConfig({ + transactions: { enabled: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + balance: { enabled: undefined as any }, + }); + const result = getTransactionsConfig(appConfig); + expect(result).toEqual({ enabled: false }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + }); +}); + +describe('getBalanceConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.CHECK_BALANCE; + delete process.env.START_BALANCE; + }); + + describe('when appConfig is not provided', () => { + it('should return config based on environment variables', () => { + process.env.CHECK_BALANCE = 'true'; + process.env.START_BALANCE = '1000'; + const result = getBalanceConfig(); + expect(result).toEqual({ + enabled: true, + startBalance: 1000, + }); + }); + + it('should return empty config when no env vars are set', () => { + const result = getBalanceConfig(); + expect(result).toEqual({ enabled: false }); + }); + + it('should handle CHECK_BALANCE true without START_BALANCE', () => { + process.env.CHECK_BALANCE = 'true'; + const result = getBalanceConfig(); + expect(result).toEqual({ + enabled: true, + }); + }); + + it('should handle START_BALANCE without CHECK_BALANCE', () => { + process.env.START_BALANCE = '5000'; + const result = getBalanceConfig(); + expect(result).toEqual({ + enabled: false, + startBalance: 5000, + }); + }); + }); + + describe('when appConfig is provided', () => { + it('should merge appConfig balance with env config', () => { + process.env.CHECK_BALANCE = 'true'; + process.env.START_BALANCE = '1000'; + const appConfig = createTestAppConfig({ + balance: { + enabled: false, + startBalance: 2000, + autoRefillEnabled: true, + }, + }); + const result = getBalanceConfig(appConfig); + expect(result).toEqual({ + enabled: false, + startBalance: 2000, + autoRefillEnabled: true, + }); + }); + + it('should use env config when appConfig balance is not provided', () => { + process.env.CHECK_BALANCE = 'true'; + process.env.START_BALANCE = '3000'; + const appConfig = createTestAppConfig(); + const result = getBalanceConfig(appConfig); + expect(result).toEqual({ + enabled: true, + startBalance: 3000, + }); + }); + + it('should handle appConfig with null balance', () => { + process.env.CHECK_BALANCE = 'true'; + const appConfig = createTestAppConfig({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + balance: null as any, + }); + const result = getBalanceConfig(appConfig); + expect(result).toEqual({ + enabled: true, + }); + }); + }); +}); diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index d7307a1b3..8bc9c9af2 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -1,7 +1,8 @@ import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; -import type { TCustomConfig, TEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; import type { AppConfig } from '~/types'; import { isEnabled, normalizeEndpointName } from '~/utils'; +import { logger } from '@librechat/data-schemas'; /** * Retrieves the balance configuration object @@ -20,6 +21,32 @@ export function getBalanceConfig(appConfig?: AppConfig): Partial; export type TBalanceConfig = z.infer; +export type TTransactionsConfig = z.infer; export const turnstileOptionsSchema = z .object({ @@ -601,6 +602,7 @@ export type TStartupConfig = { interface?: TInterfaceConfig; turnstile?: TTurnstileConfig; balance?: TBalanceConfig; + transactions?: TTransactionsConfig; discordLoginEnabled: boolean; facebookLoginEnabled: boolean; githubLoginEnabled: boolean; @@ -768,6 +770,10 @@ export const balanceSchema = z.object({ refillAmount: z.number().optional().default(10000), }); +export const transactionsSchema = z.object({ + enabled: z.boolean().optional().default(true), +}); + export const memorySchema = z.object({ disabled: z.boolean().optional(), validKeys: z.array(z.string()).optional(), @@ -821,6 +827,7 @@ export const configSchema = z.object({ }) .default({ socialLogins: defaultSocialLogins }), balance: balanceSchema.optional(), + transactions: transactionsSchema.optional(), speech: z .object({ tts: ttsSchema.optional(),