🪢 chore: Consolidate Pricing and Tx Imports After tx.js Module Removal (#12086)

* 🧹 chore: resolve imports due to rebase

* chore: Update model mocks in unit tests for consistency

- Consolidated model mock implementations across various test files to streamline setup and reduce redundancy.
- Removed duplicate mock definitions for `getMultiplier` and `getCacheMultiplier`, ensuring a unified approach in `recordCollectedUsage.spec.js`, `openai.spec.js`, `responses.unit.spec.js`, and `abortMiddleware.spec.js`.
- Enhanced clarity and maintainability of test files by aligning mock structures with the latest model updates.

* fix: Safeguard token credit checks in transaction tests

- Updated assertions in `transaction.spec.ts` to handle potential null values for `updatedBalance` by using optional chaining.
- Enhanced robustness of tests related to token credit calculations, ensuring they correctly account for scenarios where the balance may not be found.

* chore: transaction methods with bulk insert functionality

- Introduced `bulkInsertTransactions` method in `transaction.ts` to facilitate batch insertion of transaction documents.
- Updated test file `transactions.bulk-parity.spec.ts` to utilize new pricing function assignments and handle potential null values in calculations, improving test robustness.
- Refactored pricing function initialization for clarity and consistency.

* refactor: Enhance type definitions and introduce new utility functions for model matching

- Added `findMatchingPattern` and `matchModelName` utility functions to improve model name matching logic in transaction methods.
- Updated type definitions for `findMatchingPattern` to accept a more specific tokensMap structure, enhancing type safety.
- Refactored `dbMethods` initialization in `transactions.bulk-parity.spec.ts` to include the new utility functions, improving test clarity and functionality.

* refactor: Update database method imports and enhance transaction handling

- Refactored `abortMiddleware.js` to utilize centralized database methods for message handling and conversation retrieval, improving code consistency.
- Enhanced `bulkInsertTransactions` in `transaction.ts` to handle empty document arrays gracefully and added error logging for better debugging.
- Updated type definitions in `transactions.ts` to enforce stricter typing for token types, enhancing type safety across transaction methods.
- Improved test setup in `transactions.bulk-parity.spec.ts` by refining pricing function assignments and ensuring robust handling of potential null values.

* refactor: Update database method references and improve transaction multiplier handling

- Refactored `client.js` to update database method references for `bulkInsertTransactions` and `updateBalance`, ensuring consistency in method usage.
- Enhanced transaction multiplier calculations in `transaction.spec.ts` to provide fallback values for write and read multipliers, improving robustness in cost calculations across structured token spending tests.
This commit is contained in:
Danny Avila 2026-03-05 16:01:52 -05:00 committed by GitHub
parent 8fafda47c2
commit d24fe17a4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 123 additions and 427 deletions

View file

@ -79,11 +79,6 @@ jest.mock('~/server/services/ToolService', () => ({
const mockGetMultiplier = jest.fn().mockReturnValue(1); const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({ jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()), createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
@ -110,6 +105,8 @@ jest.mock('~/models', () => ({
bulkInsertTransactions: mockBulkInsertTransactions, bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens, spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens, spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]), getConvoFiles: jest.fn().mockResolvedValue([]),
})); }));

View file

@ -103,11 +103,6 @@ jest.mock('~/server/services/ToolService', () => ({
const mockGetMultiplier = jest.fn().mockReturnValue(1); const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({ jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()), createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
@ -136,6 +131,8 @@ jest.mock('~/models', () => ({
bulkInsertTransactions: mockBulkInsertTransactions, bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens, spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens, spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]), getConvoFiles: jest.fn().mockResolvedValue([]),
saveConvo: jest.fn().mockResolvedValue({}), saveConvo: jest.fn().mockResolvedValue({}),
getConvo: jest.fn().mockResolvedValue(null), getConvo: jest.fn().mockResolvedValue(null),

View file

@ -46,8 +46,6 @@ const {
removeNullishValues, removeNullishValues,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { updateBalance, bulkInsertTransactions } = require('~/models');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { createContextHandlers } = require('~/app/clients/prompts'); const { createContextHandlers } = require('~/app/clients/prompts');
const { getMCPServerTools } = require('~/server/services/Config'); const { getMCPServerTools } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient'); const BaseClient = require('~/app/clients/BaseClient');
@ -631,8 +629,8 @@ class AgentClient extends BaseClient {
{ {
spendTokens: db.spendTokens, spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens, spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier }, pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance }, bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
}, },
{ {
user: this.user ?? this.options.req.user?.id, user: this.user ?? this.options.req.user?.id,

View file

@ -24,7 +24,6 @@ const {
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { createToolEndCallback } = require('~/server/controllers/agents/callbacks'); const { createToolEndCallback } = require('~/server/controllers/agents/callbacks');
const { findAccessibleResources } = require('~/server/services/PermissionService'); const { findAccessibleResources } = require('~/server/services/PermissionService');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const db = require('~/models'); const db = require('~/models');
/** /**
@ -494,7 +493,7 @@ const OpenAIChatCompletionController = async (req, res) => {
{ {
spendTokens: db.spendTokens, spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens, spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier }, pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
}, },
{ {

View file

@ -21,14 +21,8 @@ const mockRecordCollectedUsage = jest
jest.mock('~/models', () => ({ jest.mock('~/models', () => ({
spendTokens: (...args) => mockSpendTokens(...args), spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
}));
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier, getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier, getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/models', () => ({
updateBalance: mockUpdateBalance, updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions, bulkInsertTransactions: mockBulkInsertTransactions,
})); }));

View file

@ -36,7 +36,6 @@ const {
} = require('~/server/controllers/agents/callbacks'); } = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { findAccessibleResources } = require('~/server/services/PermissionService'); const { findAccessibleResources } = require('~/server/services/PermissionService');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const db = require('~/models'); const db = require('~/models');
/** @type {import('@librechat/api').AppConfig | null} */ /** @type {import('@librechat/api').AppConfig | null} */
@ -514,7 +513,7 @@ const createResponse = async (req, res) => {
{ {
spendTokens: db.spendTokens, spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens, spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier }, pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
}, },
{ {
@ -668,7 +667,7 @@ const createResponse = async (req, res) => {
{ {
spendTokens: db.spendTokens, spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens, spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier }, pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
}, },
{ {

View file

@ -8,13 +8,11 @@ const {
recordCollectedUsage, recordCollectedUsage,
sanitizeMessageForTransmit, sanitizeMessageForTransmit,
} = require('@librechat/api'); } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { saveMessage, getConvo, updateBalance, bulkInsertTransactions } = require('~/models');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const clearPendingReq = require('~/cache/clearPendingReq'); const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error'); const { sendError } = require('~/server/middleware/error');
const { abortRun } = require('./abortRun'); const { abortRun } = require('./abortRun');
const db = require('~/models');
/** /**
* Spend tokens for all models from collected usage. * Spend tokens for all models from collected usage.
@ -44,10 +42,10 @@ async function spendCollectedUsage({
await recordCollectedUsage( await recordCollectedUsage(
{ {
spendTokens, spendTokens: db.spendTokens,
spendStructuredTokens, spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier }, pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance }, bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
}, },
{ {
user: userId, user: userId,
@ -123,13 +121,13 @@ async function abortMessage(req, res) {
}); });
} else { } else {
// Fallback: no collected usage, use text-based token counting for primary model only // Fallback: no collected usage, use text-based token counting for primary model only
await spendTokens( await db.spendTokens(
{ ...responseMessage, context: 'incomplete', user: userId }, { ...responseMessage, context: 'incomplete', user: userId },
{ promptTokens, completionTokens }, { promptTokens, completionTokens },
); );
} }
await saveMessage( await db.saveMessage(
{ {
userId: req?.user?.id, userId: req?.user?.id,
isTemporary: req?.body?.isTemporary, isTemporary: req?.body?.isTemporary,
@ -140,7 +138,7 @@ async function abortMessage(req, res) {
); );
// Get conversation for title // Get conversation for title
const conversation = await getConvo(userId, conversationId); const conversation = await db.getConvo(userId, conversationId);
const finalEvent = { const finalEvent = {
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat', title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',

View file

@ -20,8 +20,6 @@ const mockRecordCollectedUsage = jest
const mockGetMultiplier = jest.fn().mockReturnValue(1); const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/data-schemas', () => ({
logger: { logger: {
debug: jest.fn(), debug: jest.fn(),
@ -65,6 +63,10 @@ jest.mock('~/models', () => ({
getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }), getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }),
updateBalance: mockUpdateBalance, updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions, bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
})); }));
jest.mock('./abortRun', () => ({ jest.mock('./abortRun', () => ({

View file

@ -30,11 +30,6 @@ jest.mock('~/server/controllers/assistants/v2', () => ({
deleteResourceFileId: jest.fn(), deleteResourceFileId: jest.fn(),
})); }));
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn().mockResolvedValue({}),
removeAgentResourceFiles: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({ jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(), getOpenAIClient: jest.fn(),
})); }));
@ -47,6 +42,8 @@ jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }), createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }),
updateFileUsage: jest.fn(), updateFileUsage: jest.fn(),
deleteFiles: jest.fn(), deleteFiles: jest.fn(),
addAgentResourceFile: jest.fn().mockResolvedValue({}),
removeAgentResourceFiles: jest.fn(),
})); }));
jest.mock('~/server/utils/getFileStrategy', () => ({ jest.mock('~/server/utils/getFileStrategy', () => ({

View file

@ -14,10 +14,12 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server'; import { MongoMemoryServer } from 'mongodb-memory-server';
import { import {
tokenValues,
CANCEL_RATE, CANCEL_RATE,
createMethods, createMethods,
balanceSchema, balanceSchema,
transactionSchema, transactionSchema,
premiumTokenValues,
} from '@librechat/data-schemas'; } from '@librechat/data-schemas';
import type { PricingFns, TxMetadata } from './transactions'; import type { PricingFns, TxMetadata } from './transactions';
import { import {
@ -26,6 +28,26 @@ import {
prepareTokenSpend, prepareTokenSpend,
} from './transactions'; } from './transactions';
/** Inlined from packages/data-schemas/src/methods/test-helpers.ts — keep in sync */
function findMatchingPattern(
modelName: string,
tokensMap: Record<string, number | Record<string, number>>,
): string | undefined {
const keys = Object.keys(tokensMap);
const lowerModelName = modelName.toLowerCase();
for (let i = keys.length - 1; i >= 0; i--) {
if (lowerModelName.includes(keys[i])) {
return keys[i];
}
}
return undefined;
}
/** Inlined from packages/data-schemas/src/methods/test-helpers.ts — keep in sync */
function matchModelName(modelName: string, _endpoint?: string): string | undefined {
return typeof modelName === 'string' ? modelName : undefined;
}
jest.mock('@librechat/data-schemas', () => { jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas'); const actual = jest.requireActual('@librechat/data-schemas');
return { return {
@ -34,29 +56,23 @@ jest.mock('@librechat/data-schemas', () => {
}; };
}); });
// Real pricing functions from api/models/tx.js — same ones the legacy path uses
/* eslint-disable @typescript-eslint/no-require-imports */
const {
getMultiplier,
getCacheMultiplier,
tokenValues,
premiumTokenValues,
} = require('../../../../api/models/tx.js');
/* eslint-enable @typescript-eslint/no-require-imports */
const pricing: PricingFns = { getMultiplier, getCacheMultiplier };
let mongoServer: MongoMemoryServer; let mongoServer: MongoMemoryServer;
let Transaction: mongoose.Model<unknown>; let Transaction: mongoose.Model<unknown>;
let Balance: mongoose.Model<unknown>; let Balance: mongoose.Model<unknown>;
let dbMethods: ReturnType<typeof createMethods>; let dbMethods: ReturnType<typeof createMethods>;
let pricing: PricingFns;
let getMultiplier: ReturnType<typeof createMethods>['getMultiplier'];
let getCacheMultiplier: ReturnType<typeof createMethods>['getCacheMultiplier'];
beforeAll(async () => { beforeAll(async () => {
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri()); await mongoose.connect(mongoServer.getUri());
Transaction = mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema); Transaction = mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema);
Balance = mongoose.models.Balance || mongoose.model('Balance', balanceSchema); Balance = mongoose.models.Balance || mongoose.model('Balance', balanceSchema);
dbMethods = createMethods(mongoose); dbMethods = createMethods(mongoose, { matchModelName, findMatchingPattern });
getMultiplier = dbMethods.getMultiplier;
getCacheMultiplier = dbMethods.getCacheMultiplier;
pricing = { getMultiplier, getCacheMultiplier };
}); });
afterAll(async () => { afterAll(async () => {
@ -536,8 +552,13 @@ describe('Multi-entry batch parity', () => {
const premiumCompletionRate = (premiumTokenValues as Record<string, Record<string, number>>)[ const premiumCompletionRate = (premiumTokenValues as Record<string, Record<string, number>>)[
model model
].completion; ].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); const promptMultiplier = getMultiplier({
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost = const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate + tokenUsage.promptTokens.input * premiumPromptRate +

View file

@ -3,9 +3,11 @@ import type { TCustomConfig, TTransactionsConfig } from 'librechat-data-provider
import type { TransactionData } from '@librechat/data-schemas'; import type { TransactionData } from '@librechat/data-schemas';
import type { EndpointTokenConfig } from '~/types/tokens'; import type { EndpointTokenConfig } from '~/types/tokens';
type TokenType = 'prompt' | 'completion';
interface GetMultiplierParams { interface GetMultiplierParams {
valueKey?: string; valueKey?: string;
tokenType?: string; tokenType?: TokenType;
model?: string; model?: string;
endpointTokenConfig?: EndpointTokenConfig; endpointTokenConfig?: EndpointTokenConfig;
inputTokenCount?: number; inputTokenCount?: number;
@ -34,14 +36,14 @@ interface BaseTxData {
} }
interface StandardTxData extends BaseTxData { interface StandardTxData extends BaseTxData {
tokenType: string; tokenType: TokenType;
rawAmount: number; rawAmount: number;
inputTokenCount?: number; inputTokenCount?: number;
valueKey?: string; valueKey?: string;
} }
interface StructuredTxData extends BaseTxData { interface StructuredTxData extends BaseTxData {
tokenType: string; tokenType: TokenType;
inputTokens?: number; inputTokens?: number;
writeTokens?: number; writeTokens?: number;
readTokens?: number; readTokens?: number;

View file

@ -1,16 +1,8 @@
/** Configuration object mapping model keys to their respective prompt, completion rates, and context limit
*
* Note: the [key: string]: unknown is not in the original JSDoc typedef in /api/typedefs.js, but I've included it since
* getModelMaxOutputTokens calls getModelTokenValue with a key of 'output', which was not in the original JSDoc typedef,
* but would be referenced in a TokenConfig in the if(matchedPattern) portion of getModelTokenValue.
* So in order to preserve functionality for that case and any others which might reference an additional key I'm unaware of,
* I've included it here until the interface can be typed more tightly.
*/
export interface TokenConfig { export interface TokenConfig {
[key: string]: number;
prompt: number; prompt: number;
completion: number; completion: number;
context: number; context: number;
[key: string]: unknown;
} }
/** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */ /** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */

View file

@ -85,7 +85,10 @@ export interface CreateMethodsDeps {
/** Matches a model name to a canonical key. From @librechat/api. */ /** Matches a model name to a canonical key. From @librechat/api. */
matchModelName?: (model: string, endpoint?: string) => string | undefined; matchModelName?: (model: string, endpoint?: string) => string | undefined;
/** Finds the first key in values whose key is a substring of model. From @librechat/api. */ /** Finds the first key in values whose key is a substring of model. From @librechat/api. */
findMatchingPattern?: (model: string, values: Record<string, unknown>) => string | undefined; findMatchingPattern?: (
model: string,
values: Record<string, number | Record<string, number>>,
) => string | undefined;
/** Removes all ACL permissions for a resource. From PermissionService. */ /** Removes all ACL permissions for a resource. From PermissionService. */
removeAllPermissions?: (params: { resourceType: string; resourceId: unknown }) => Promise<void>; removeAllPermissions?: (params: { resourceType: string; resourceId: unknown }) => Promise<void>;
/** Returns a cache store for the given key. From getLogStores. */ /** Returns a cache store for the given key. From getLogStores. */

View file

@ -11,7 +11,7 @@
*/ */
export function findMatchingPattern( export function findMatchingPattern(
modelName: string, modelName: string,
tokensMap: Record<string, unknown>, tokensMap: Record<string, number | Record<string, number>>,
): string | undefined { ): string | undefined {
const keys = Object.keys(tokensMap); const keys = Object.keys(tokensMap);
const lowerModelName = modelName.toLowerCase(); const lowerModelName = modelName.toLowerCase();

View file

@ -247,8 +247,8 @@ describe('Structured Token Spending Tests', () => {
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' }); const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' }); const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
// Act // Act
const result = await spendStructuredTokens(txData, tokenUsage); const result = await spendStructuredTokens(txData, tokenUsage);
@ -256,8 +256,8 @@ describe('Structured Token Spending Tests', () => {
// Calculate expected costs. // Calculate expected costs.
const expectedPromptCost = const expectedPromptCost =
tokenUsage.promptTokens.input * promptMultiplier + tokenUsage.promptTokens.input * promptMultiplier +
tokenUsage.promptTokens.write * (writeMultiplier ?? 0) + tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * (readMultiplier ?? 0); tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier; const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost; const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const expectedBalance = initialBalance - expectedTotalCost; const expectedBalance = initialBalance - expectedTotalCost;
@ -813,13 +813,18 @@ describe('Premium Token Pricing Integration Tests', () => {
const premiumPromptRate = premiumTokenValues[model].prompt; const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion; const premiumCompletionRate = premiumTokenValues[model].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); const promptMultiplier = getMultiplier({
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost = const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate + tokenUsage.promptTokens.input * premiumPromptRate +
tokenUsage.promptTokens.write * (writeMultiplier ?? 0) + tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * (readMultiplier ?? 0); tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost; const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
@ -859,13 +864,18 @@ describe('Premium Token Pricing Integration Tests', () => {
const standardPromptRate = tokenValues[model].prompt; const standardPromptRate = tokenValues[model].prompt;
const standardCompletionRate = tokenValues[model].completion; const standardCompletionRate = tokenValues[model].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); const promptMultiplier = getMultiplier({
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost = const expectedPromptCost =
tokenUsage.promptTokens.input * standardPromptRate + tokenUsage.promptTokens.input * standardPromptRate +
tokenUsage.promptTokens.write * (writeMultiplier ?? 0) + tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * (readMultiplier ?? 0); tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate; const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost; const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
@ -900,7 +910,7 @@ describe('Premium Token Pricing Integration Tests', () => {
promptTokens * standardPromptRate + completionTokens * standardCompletionRate; promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId }); const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
}); });
test('spendTokens should apply premium pricing for gemini-3.1-pro-preview above threshold', async () => { test('spendTokens should apply premium pricing for gemini-3.1-pro-preview above threshold', async () => {
@ -929,7 +939,7 @@ describe('Premium Token Pricing Integration Tests', () => {
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate; promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId }); const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
}); });
test('spendTokens should apply standard pricing for gemini-3.1-pro-preview at exactly the threshold', async () => { test('spendTokens should apply standard pricing for gemini-3.1-pro-preview at exactly the threshold', async () => {
@ -958,7 +968,7 @@ describe('Premium Token Pricing Integration Tests', () => {
promptTokens * standardPromptRate + completionTokens * standardCompletionRate; promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId }); const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
}); });
test('spendStructuredTokens should apply premium pricing for gemini-3.1 when total input exceeds threshold', async () => { test('spendStructuredTokens should apply premium pricing for gemini-3.1 when total input exceeds threshold', async () => {
@ -992,8 +1002,13 @@ describe('Premium Token Pricing Integration Tests', () => {
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt; const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion; const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); const promptMultiplier = getMultiplier({
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost = const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate + tokenUsage.promptTokens.input * premiumPromptRate +
@ -1004,7 +1019,7 @@ describe('Premium Token Pricing Integration Tests', () => {
const updatedBalance = await Balance.findOne({ user: userId }); const updatedBalance = await Balance.findOne({ user: userId });
expect(totalInput).toBeGreaterThan(premiumTokenValues['gemini-3.1'].threshold); expect(totalInput).toBeGreaterThan(premiumTokenValues['gemini-3.1'].threshold);
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
}); });
test('non-premium models should not be affected by inputTokenCount regardless of prompt size', async () => { test('non-premium models should not be affected by inputTokenCount regardless of prompt size', async () => {
@ -1036,339 +1051,3 @@ describe('Premium Token Pricing Integration Tests', () => {
expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); expect(updatedBalance?.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
}); });
}); });
describe('Bulk path parity', () => {
/**
* Each test here mirrors an existing legacy test above, replacing spendTokens/
* spendStructuredTokens with recordCollectedUsage + bulk deps.
* The balance deduction and transaction document fields must be numerically identical.
*/
let bulkDeps;
let methods;
beforeEach(() => {
methods = createMethods(mongoose);
bulkDeps = {
spendTokens: () => Promise.resolve(),
spendStructuredTokens: () => Promise.resolve(),
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: {
insertMany: methods.bulkInsertTransactions,
updateBalance: methods.updateBalance,
},
};
});
test('balance should decrease when spending tokens via bulk path', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const promptTokens = 100;
const completionTokens = 50;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model,
context: 'test',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const updatedBalance = await Balance.findOne({ user: userId });
const promptMultiplier = getMultiplier({
model,
tokenType: 'prompt',
inputTokenCount: promptTokens,
});
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: promptTokens,
});
const expectedTotalCost =
promptTokens * promptMultiplier + completionTokens * completionMultiplier;
const expectedBalance = initialBalance - expectedTotalCost;
expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
});
test('bulk path should not update balance when balance.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model,
context: 'test',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model }],
});
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBe(initialBalance);
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2); // transactions still recorded
});
test('bulk path should not insert when transactions.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'gpt-3.5-turbo',
context: 'test',
balance: { enabled: true },
transactions: { enabled: false },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path handles incomplete context for completion tokens — same CANCEL_RATE as legacy', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const promptTokens = 10;
const completionTokens = 50;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-convo',
model,
context: 'incomplete',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const txns = await Transaction.find({ user: userId }).lean();
const completionTx = txns.find((t) => t.tokenType === 'completion');
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: promptTokens,
});
expect(completionTx.tokenValue).toBeCloseTo(-completionTokens * completionMultiplier * 1.15, 0);
});
test('bulk path structured tokens — balance deduction matches legacy spendStructuredTokens', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const promptInput = 11;
const promptWrite = 140522;
const promptRead = 0;
const completionTokens = 5;
const totalInput = promptInput + promptWrite + promptRead;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-convo',
model,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [
{
input_tokens: promptInput,
output_tokens: completionTokens,
model,
input_token_details: { cache_creation: promptWrite, cache_read: promptRead },
},
],
});
const promptMultiplier = getMultiplier({
model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost =
promptInput * promptMultiplier + promptWrite * writeMultiplier + promptRead * readMultiplier;
const expectedCompletionCost = completionTokens * completionMultiplier;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const expectedBalance = initialBalance - expectedTotalCost;
const updatedBalance = await Balance.findOne({ user: userId });
expect(Math.abs(updatedBalance.tokenCredits - expectedBalance)).toBeLessThan(100);
});
test('premium pricing above threshold via bulk path — same balance as legacy', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const promptTokens = 250000;
const completionTokens = 500;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-premium',
model,
context: 'test',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
const expectedCost =
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('real-world multi-entry batch: 5 sequential tool calls — same total deduction as 5 legacy spendTokens calls', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-5-20251101';
const calls = [
{ input_tokens: 31596, output_tokens: 151 },
{ input_tokens: 35368, output_tokens: 150 },
{ input_tokens: 58362, output_tokens: 295 },
{ input_tokens: 112604, output_tokens: 193 },
{ input_tokens: 257440, output_tokens: 2217 },
];
let expectedTotalCost = 0;
for (const { input_tokens, output_tokens } of calls) {
const pm = getMultiplier({ model, tokenType: 'prompt', inputTokenCount: input_tokens });
const cm = getMultiplier({ model, tokenType: 'completion', inputTokenCount: input_tokens });
expectedTotalCost += input_tokens * pm + output_tokens * cm;
}
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-sequential',
model,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: calls.map((c) => ({ ...c, model })),
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(10); // 5 calls × 2 docs (prompt + completion)
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
});
test('bulk path should save transaction but not update balance when balance disabled, transactions enabled', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'gpt-3.5-turbo',
context: 'test',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
expect(txns[0].rawAmount).toBeDefined();
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path structured tokens should not save when transactions.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'claude-3-5-sonnet',
context: 'message',
balance: { enabled: true },
transactions: { enabled: false },
collectedUsage: [
{
input_tokens: 10,
output_tokens: 5,
model: 'claude-3-5-sonnet',
input_token_details: { cache_creation: 100, cache_read: 5 },
},
],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path structured tokens should save but not update balance when balance disabled', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'claude-3-5-sonnet',
context: 'message',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [
{
input_tokens: 10,
output_tokens: 5,
model: 'claude-3-5-sonnet',
input_token_details: { cache_creation: 100, cache_read: 5 },
},
],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
const promptTx = txns.find((t) => t.tokenType === 'prompt');
expect(promptTx.inputTokens).toBe(-10);
expect(promptTx.writeTokens).toBe(-100);
expect(promptTx.readTokens).toBe(-5);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
});

View file

@ -1,7 +1,7 @@
import logger from '~/config/winston'; import logger from '~/config/winston';
import type { FilterQuery, Model, Types } from 'mongoose'; import type { FilterQuery, Model, Types } from 'mongoose';
import type { IBalance, IBalanceUpdate, TransactionData } from '~/types';
import type { ITransaction } from '~/schema/transaction'; import type { ITransaction } from '~/schema/transaction';
import type { IBalance, IBalanceUpdate } from '~/types';
const cancelRate = 1.15; const cancelRate = 1.15;
@ -408,7 +408,22 @@ export function createTransactionMethods(
return Balance.deleteMany(filter); return Balance.deleteMany(filter);
} }
async function bulkInsertTransactions(docs: TransactionData[]): Promise<void> {
if (!docs.length) {
return;
}
try {
const Transaction = mongoose.models.Transaction;
await Transaction.insertMany(docs);
} catch (error) {
logger.error('[bulkInsertTransactions] Error inserting transaction docs:', error);
throw error;
}
}
return { return {
updateBalance,
bulkInsertTransactions,
findBalanceByUser, findBalanceByUser,
upsertBalanceFields, upsertBalanceFields,
getTransactions, getTransactions,

View file

@ -20,7 +20,10 @@ export interface TxDeps {
/** From @librechat/api — matches a model name to a canonical key. */ /** From @librechat/api — matches a model name to a canonical key. */
matchModelName: (model: string, endpoint?: string) => string | undefined; matchModelName: (model: string, endpoint?: string) => string | undefined;
/** From @librechat/api — finds the longest key in `values` whose key is a substring of `model`. */ /** From @librechat/api — finds the longest key in `values` whose key is a substring of `model`. */
findMatchingPattern: (model: string, values: Record<string, unknown>) => string | undefined; findMatchingPattern: (
model: string,
values: Record<string, number | Record<string, number>>,
) => string | undefined;
} }
export const defaultRate = 6; export const defaultRate = 6;