mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-08 09:02:36 +01:00
🪢 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:
parent
8fafda47c2
commit
d24fe17a4b
17 changed files with 123 additions and 427 deletions
|
|
@ -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([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue