2024-08-24 04:36:08 -04:00
|
|
|
|
const mongoose = require('mongoose');
|
🧮 refactor: Bulk Transactions & Balance Updates for Token Spending (#11996)
* refactor: transaction handling by integrating pricing and bulk write operations
- Updated `recordCollectedUsage` to accept pricing functions and bulk write operations, improving transaction management.
- Refactored `AgentClient` and related controllers to utilize the new transaction handling capabilities, ensuring better performance and accuracy in token spending.
- Added tests to validate the new functionality, ensuring correct behavior for both standard and bulk transaction paths.
- Introduced a new `transactions.ts` file to encapsulate transaction-related logic and types, enhancing code organization and maintainability.
* chore: reorganize imports in agents client controller
- Moved `getMultiplier` and `getCacheMultiplier` imports to maintain consistency and clarity in the import structure.
- Removed duplicate import of `updateBalance` and `bulkInsertTransactions`, streamlining the code for better readability.
* refactor: add TransactionData type and CANCEL_RATE constant to data-schemas
Establishes a single source of truth for the transaction document shape
and the incomplete-context billing rate constant, both consumed by
packages/api and api/.
* refactor: use proper types in data-schemas transaction methods
- Replace `as unknown as { tokenCredits }` with `lean<IBalance>()`
- Use `TransactionData[]` instead of `Record<string, unknown>[]`
for bulkInsertTransactions parameter
- Add JSDoc noting insertMany bypasses document middleware
- Remove orphan section comment in methods/index.ts
* refactor: use shared types in transactions.ts, fix bulk write logic
- Import CANCEL_RATE from data-schemas instead of local duplicate
- Import TransactionData from data-schemas for PreparedEntry/BulkWriteDeps
- Use tilde alias for EndpointTokenConfig import
- Pass valueKey through to getMultiplier
- Only sum tokenValue for balance-enabled docs in bulkWriteTransactions
- Consolidate two loops into single-pass map
* refactor: remove duplicate updateBalance from Transaction.js
Import updateBalance from ~/models (sourced from data-schemas) instead
of maintaining a second copy. Also import CANCEL_RATE from data-schemas
and remove the Balance model import (no longer needed directly).
* fix: test real spendCollectedUsage instead of IIFE replica
Export spendCollectedUsage from abortMiddleware.js and rewrite the test
file to import and test the actual function. Previously the tests ran
against a hand-written replica that could silently diverge from the real
implementation.
* test: add transactions.spec.ts and restore regression comments
Add 22 direct unit tests for transactions.ts financial logic covering
prepareTokenSpend, prepareStructuredTokenSpend, bulkWriteTransactions,
CANCEL_RATE paths, NaN guards, disabled transactions, zero tokens,
cache multipliers, and balance-enabled filtering.
Restore critical regression documentation comments in
recordCollectedUsage.spec.js explaining which production bugs the
tests guard against.
* fix: widen setValues type to include lastRefill
The UpdateBalanceParams.setValues type was Partial<Pick<IBalance,
'tokenCredits'>> which excluded lastRefill — used by
createAutoRefillTransaction. Widen to also pick 'lastRefill'.
* test: use real MongoDB for bulkWriteTransactions tests
Replace mock-based bulkWriteTransactions tests with real DB tests using
MongoMemoryServer. Pure function tests (prepareTokenSpend,
prepareStructuredTokenSpend) remain mock-based since they don't touch
DB. Add end-to-end integration tests that verify the full prepare →
bulk write → DB state pipeline with real Transaction and Balance models.
* chore: update @librechat/agents dependency to version 3.1.54 in package-lock.json and related package.json files
* test: add bulk path parity tests proving identical DB outcomes
Three test suites proving the bulk path (prepareTokenSpend/
prepareStructuredTokenSpend + bulkWriteTransactions) produces
numerically identical results to the legacy path for all scenarios:
- usage.bulk-parity.spec.ts: mirrors all legacy recordCollectedUsage
tests; asserts same return values and verifies metadata fields on
the insertMany docs match what spendTokens args would carry
- transactions.bulk-parity.spec.ts: real-DB tests using actual
getMultiplier/getCacheMultiplier pricing functions; asserts exact
tokenValue, rate, rawAmount and balance deductions for standard
tokens, structured/cache tokens, CANCEL_RATE, premium pricing,
multi-entry batches, and edge cases (NaN, zero, disabled)
- Transaction.spec.js: adds describe('Bulk path parity') that mirrors
7 key legacy tests via recordCollectedUsage + bulk deps against
real MongoDB, asserting same balance deductions and doc counts
* refactor: update llmConfig structure to use modelKwargs for reasoning effort
Refactor the llmConfig in getOpenAILLMConfig to store reasoning effort within modelKwargs instead of directly on llmConfig. This change ensures consistency in the configuration structure and improves clarity in the handling of reasoning properties in the tests.
* test: update performance checks in processAssistantMessage tests
Revise the performance assertions in the processAssistantMessage tests to ensure that each message processing time remains under 100ms, addressing potential ReDoS vulnerabilities. This change enhances the reliability of the tests by focusing on maximum processing time rather than relative ratios.
* test: fill parity test gaps — model fallback, abort context, structured edge cases
- usage.bulk-parity: add undefined model fallback test
- transactions.bulk-parity: add abort context test (txns inserted,
balance unchanged when balance not passed), fix readTokens type cast
- Transaction.spec: add 3 missing mirrors — balance disabled with
transactions enabled, structured transactions disabled, structured
balance disabled
* fix: deduct balance before inserting transactions to prevent orphaned docs
Swap the order in bulkWriteTransactions: updateBalance runs before
insertMany. If updateBalance fails (after exhausting retries), no
transaction documents are written — avoiding the inconsistent state
where transactions exist in MongoDB with no corresponding balance
deduction.
* chore: import order
* test: update config.spec.ts for OpenRouter reasoning in modelKwargs
Same fix as llm.spec.ts — OpenRouter reasoning is now passed via
modelKwargs instead of llmConfig.reasoning directly.
2026-03-01 12:26:36 -05:00
|
|
|
|
const { recordCollectedUsage } = require('@librechat/api');
|
|
|
|
|
|
const { createMethods } = require('@librechat/data-schemas');
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
2026-02-06 18:35:36 -05:00
|
|
|
|
const { getMultiplier, getCacheMultiplier, premiumTokenValues, tokenValues } = require('./tx');
|
2025-09-06 00:21:02 +09:00
|
|
|
|
const { createTransaction, createStructuredTransaction } = require('./Transaction');
|
🧮 refactor: Bulk Transactions & Balance Updates for Token Spending (#11996)
* refactor: transaction handling by integrating pricing and bulk write operations
- Updated `recordCollectedUsage` to accept pricing functions and bulk write operations, improving transaction management.
- Refactored `AgentClient` and related controllers to utilize the new transaction handling capabilities, ensuring better performance and accuracy in token spending.
- Added tests to validate the new functionality, ensuring correct behavior for both standard and bulk transaction paths.
- Introduced a new `transactions.ts` file to encapsulate transaction-related logic and types, enhancing code organization and maintainability.
* chore: reorganize imports in agents client controller
- Moved `getMultiplier` and `getCacheMultiplier` imports to maintain consistency and clarity in the import structure.
- Removed duplicate import of `updateBalance` and `bulkInsertTransactions`, streamlining the code for better readability.
* refactor: add TransactionData type and CANCEL_RATE constant to data-schemas
Establishes a single source of truth for the transaction document shape
and the incomplete-context billing rate constant, both consumed by
packages/api and api/.
* refactor: use proper types in data-schemas transaction methods
- Replace `as unknown as { tokenCredits }` with `lean<IBalance>()`
- Use `TransactionData[]` instead of `Record<string, unknown>[]`
for bulkInsertTransactions parameter
- Add JSDoc noting insertMany bypasses document middleware
- Remove orphan section comment in methods/index.ts
* refactor: use shared types in transactions.ts, fix bulk write logic
- Import CANCEL_RATE from data-schemas instead of local duplicate
- Import TransactionData from data-schemas for PreparedEntry/BulkWriteDeps
- Use tilde alias for EndpointTokenConfig import
- Pass valueKey through to getMultiplier
- Only sum tokenValue for balance-enabled docs in bulkWriteTransactions
- Consolidate two loops into single-pass map
* refactor: remove duplicate updateBalance from Transaction.js
Import updateBalance from ~/models (sourced from data-schemas) instead
of maintaining a second copy. Also import CANCEL_RATE from data-schemas
and remove the Balance model import (no longer needed directly).
* fix: test real spendCollectedUsage instead of IIFE replica
Export spendCollectedUsage from abortMiddleware.js and rewrite the test
file to import and test the actual function. Previously the tests ran
against a hand-written replica that could silently diverge from the real
implementation.
* test: add transactions.spec.ts and restore regression comments
Add 22 direct unit tests for transactions.ts financial logic covering
prepareTokenSpend, prepareStructuredTokenSpend, bulkWriteTransactions,
CANCEL_RATE paths, NaN guards, disabled transactions, zero tokens,
cache multipliers, and balance-enabled filtering.
Restore critical regression documentation comments in
recordCollectedUsage.spec.js explaining which production bugs the
tests guard against.
* fix: widen setValues type to include lastRefill
The UpdateBalanceParams.setValues type was Partial<Pick<IBalance,
'tokenCredits'>> which excluded lastRefill — used by
createAutoRefillTransaction. Widen to also pick 'lastRefill'.
* test: use real MongoDB for bulkWriteTransactions tests
Replace mock-based bulkWriteTransactions tests with real DB tests using
MongoMemoryServer. Pure function tests (prepareTokenSpend,
prepareStructuredTokenSpend) remain mock-based since they don't touch
DB. Add end-to-end integration tests that verify the full prepare →
bulk write → DB state pipeline with real Transaction and Balance models.
* chore: update @librechat/agents dependency to version 3.1.54 in package-lock.json and related package.json files
* test: add bulk path parity tests proving identical DB outcomes
Three test suites proving the bulk path (prepareTokenSpend/
prepareStructuredTokenSpend + bulkWriteTransactions) produces
numerically identical results to the legacy path for all scenarios:
- usage.bulk-parity.spec.ts: mirrors all legacy recordCollectedUsage
tests; asserts same return values and verifies metadata fields on
the insertMany docs match what spendTokens args would carry
- transactions.bulk-parity.spec.ts: real-DB tests using actual
getMultiplier/getCacheMultiplier pricing functions; asserts exact
tokenValue, rate, rawAmount and balance deductions for standard
tokens, structured/cache tokens, CANCEL_RATE, premium pricing,
multi-entry batches, and edge cases (NaN, zero, disabled)
- Transaction.spec.js: adds describe('Bulk path parity') that mirrors
7 key legacy tests via recordCollectedUsage + bulk deps against
real MongoDB, asserting same balance deductions and doc counts
* refactor: update llmConfig structure to use modelKwargs for reasoning effort
Refactor the llmConfig in getOpenAILLMConfig to store reasoning effort within modelKwargs instead of directly on llmConfig. This change ensures consistency in the configuration structure and improves clarity in the handling of reasoning properties in the tests.
* test: update performance checks in processAssistantMessage tests
Revise the performance assertions in the processAssistantMessage tests to ensure that each message processing time remains under 100ms, addressing potential ReDoS vulnerabilities. This change enhances the reliability of the tests by focusing on maximum processing time rather than relative ratios.
* test: fill parity test gaps — model fallback, abort context, structured edge cases
- usage.bulk-parity: add undefined model fallback test
- transactions.bulk-parity: add abort context test (txns inserted,
balance unchanged when balance not passed), fix readTokens type cast
- Transaction.spec: add 3 missing mirrors — balance disabled with
transactions enabled, structured transactions disabled, structured
balance disabled
* fix: deduct balance before inserting transactions to prevent orphaned docs
Swap the order in bulkWriteTransactions: updateBalance runs before
insertMany. If updateBalance fails (after exhausting retries), no
transaction documents are written — avoiding the inconsistent state
where transactions exist in MongoDB with no corresponding balance
deduction.
* chore: import order
* test: update config.spec.ts for OpenRouter reasoning in modelKwargs
Same fix as llm.spec.ts — OpenRouter reasoning is now passed via
modelKwargs instead of llmConfig.reasoning directly.
2026-03-01 12:26:36 -05:00
|
|
|
|
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
2025-09-06 00:21:02 +09:00
|
|
|
|
const { Balance, Transaction } = require('~/db/models');
|
2025-03-21 22:48:11 +01:00
|
|
|
|
|
2024-08-24 04:36:08 -04:00
|
|
|
|
let mongoServer;
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
|
mongoServer = await MongoMemoryServer.create();
|
|
|
|
|
|
const mongoUri = mongoServer.getUri();
|
|
|
|
|
|
await mongoose.connect(mongoUri);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
|
await mongoose.disconnect();
|
|
|
|
|
|
await mongoServer.stop();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
|
await mongoose.connection.dropDatabase();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('Regular Token Spending Tests', () => {
|
|
|
|
|
|
test('Balance should decrease when spending tokens with spendTokens', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000; // $10.00
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: 100,
|
|
|
|
|
|
completionTokens: 50,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
await spendTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
|
|
|
|
|
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const expectedTotalCost = 100 * promptMultiplier + 50 * completionMultiplier;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const expectedBalance = initialBalance - expectedTotalCost;
|
|
|
|
|
|
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should handle zero completion tokens', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const initialBalance = 10000000;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: 100,
|
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
await spendTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const expectedCost = 100 * promptMultiplier;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should handle undefined token counts', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const initialBalance = 10000000;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const result = await spendTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert: No transaction should be created
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should handle only prompt tokens', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const initialBalance = 10000000;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = { promptTokens: 100 };
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
await spendTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
|
|
|
|
|
const expectedCost = 100 * promptMultiplier;
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
2025-03-21 22:48:11 +01:00
|
|
|
|
|
|
|
|
|
|
test('spendTokens should not update balance when balance feature is disabled', async () => {
|
2025-08-26 12:10:18 -04:00
|
|
|
|
// Arrange: Balance config is now passed directly in txData
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: false },
|
2025-03-21 22:48:11 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: 100,
|
|
|
|
|
|
completionTokens: 50,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
await spendTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: Balance should remain unchanged.
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
2024-08-24 04:36:08 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('Structured Token Spending Tests', () => {
|
|
|
|
|
|
test('Balance should decrease and rawAmount should be set when spending a large number of structured tokens', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 17613154.55; // $17.61
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'c23a18da-706c-470a-ac28-ec87ed065199',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
2025-03-21 22:48:11 +01:00
|
|
|
|
endpointTokenConfig: null,
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 11,
|
|
|
|
|
|
write: 140522,
|
|
|
|
|
|
read: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 5,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
|
|
|
|
|
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
|
|
|
|
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
|
|
|
|
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Calculate expected costs.
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const expectedPromptCost =
|
|
|
|
|
|
tokenUsage.promptTokens.input * promptMultiplier +
|
|
|
|
|
|
tokenUsage.promptTokens.write * writeMultiplier +
|
|
|
|
|
|
tokenUsage.promptTokens.read * readMultiplier;
|
|
|
|
|
|
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
|
|
|
|
|
|
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
|
|
|
|
|
const expectedBalance = initialBalance - expectedTotalCost;
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result.completion.balance).toBeLessThan(initialBalance);
|
|
|
|
|
|
const allowedDifference = 100;
|
|
|
|
|
|
expect(Math.abs(result.completion.balance - expectedBalance)).toBeLessThan(allowedDifference);
|
|
|
|
|
|
const balanceDecrease = initialBalance - result.completion.balance;
|
|
|
|
|
|
expect(balanceDecrease).toBeCloseTo(expectedTotalCost, 0);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
const expectedPromptTokenValue = -expectedPromptCost;
|
|
|
|
|
|
const expectedCompletionTokenValue = -expectedCompletionCost;
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result.prompt.prompt).toBeCloseTo(expectedPromptTokenValue, 1);
|
|
|
|
|
|
expect(result.completion.completion).toBe(expectedCompletionTokenValue);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should handle zero completion tokens in structured spending', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 17613154.55;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-convo',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 10,
|
|
|
|
|
|
write: 100,
|
|
|
|
|
|
read: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const result = await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result.prompt).toBeDefined();
|
|
|
|
|
|
expect(result.completion).toBeUndefined();
|
|
|
|
|
|
expect(result.prompt.prompt).toBeLessThan(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should handle only prompt tokens in structured spending', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 17613154.55;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-convo',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 10,
|
|
|
|
|
|
write: 100,
|
|
|
|
|
|
read: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const result = await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result.prompt).toBeDefined();
|
|
|
|
|
|
expect(result.completion).toBeUndefined();
|
|
|
|
|
|
expect(result.prompt.prompt).toBeLessThan(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should handle undefined token counts in structured spending', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 17613154.55;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-convo',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const result = await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert
|
2024-08-24 04:36:08 -04:00
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
|
prompt: undefined,
|
|
|
|
|
|
completion: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should handle incomplete context for completion tokens', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 17613154.55;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-convo',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'incomplete',
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-08-24 04:36:08 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 10,
|
|
|
|
|
|
write: 100,
|
|
|
|
|
|
read: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 50,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2024-08-24 04:36:08 -04:00
|
|
|
|
const result = await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert:
|
|
|
|
|
|
// (Assuming a multiplier for completion of 15 and a cancel rate of 1.15 as noted in the original test.)
|
|
|
|
|
|
expect(result.completion.completion).toBeCloseTo(-50 * 15 * 1.15, 0);
|
2024-08-24 04:36:08 -04:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2024-12-28 17:15:03 -05:00
|
|
|
|
|
|
|
|
|
|
describe('NaN Handling Tests', () => {
|
|
|
|
|
|
test('should skip transaction creation when rawAmount is NaN', async () => {
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Arrange
|
2024-12-28 17:15:03 -05:00
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
rawAmount: NaN,
|
|
|
|
|
|
tokenType: 'prompt',
|
2025-08-26 12:10:18 -04:00
|
|
|
|
balance: { enabled: true },
|
2024-12-28 17:15:03 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Act
|
2025-05-30 22:18:13 -04:00
|
|
|
|
const result = await createTransaction(txData);
|
2024-12-28 17:15:03 -05:00
|
|
|
|
|
2025-03-21 22:48:11 +01:00
|
|
|
|
// Assert: No transaction should be created and balance remains unchanged.
|
|
|
|
|
|
expect(result).toBeUndefined();
|
2024-12-28 17:15:03 -05:00
|
|
|
|
const balance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(balance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-09-06 00:21:02 +09:00
|
|
|
|
|
|
|
|
|
|
describe('Transactions Config Tests', () => {
|
|
|
|
|
|
test('createTransaction should not save when transactions.enabled is false', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
rawAmount: -100,
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
transactions: { enabled: false },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: No transaction should be created
|
|
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(0);
|
|
|
|
|
|
const balance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(balance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('createTransaction should save when transactions.enabled is true', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
rawAmount: -100,
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
transactions: { enabled: true },
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: Transaction should be created
|
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
|
expect(result.balance).toBeLessThan(initialBalance);
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(1);
|
|
|
|
|
|
expect(transactions[0].rawAmount).toBe(-100);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
rawAmount: -100,
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
// No transactions config provided
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: Transaction should be created (backward compatibility)
|
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
|
expect(result.balance).toBeLessThan(initialBalance);
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-3.5-turbo';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
rawAmount: -100,
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
transactions: { enabled: true },
|
|
|
|
|
|
balance: { enabled: false },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: Transaction should be created but balance unchanged
|
|
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(1);
|
|
|
|
|
|
expect(transactions[0].rawAmount).toBe(-100);
|
|
|
|
|
|
const balance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(balance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('createStructuredTransaction should not save when transactions.enabled is false', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
inputTokens: -10,
|
|
|
|
|
|
writeTokens: -100,
|
|
|
|
|
|
readTokens: -5,
|
|
|
|
|
|
transactions: { enabled: false },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createStructuredTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: No transaction should be created
|
|
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(0);
|
|
|
|
|
|
const balance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(balance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 10000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-3-5-sonnet';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-conversation-id',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
inputTokens: -10,
|
|
|
|
|
|
writeTokens: -100,
|
|
|
|
|
|
readTokens: -5,
|
|
|
|
|
|
transactions: { enabled: true },
|
|
|
|
|
|
balance: { enabled: false },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
const result = await createStructuredTransaction(txData);
|
|
|
|
|
|
|
|
|
|
|
|
// Assert: Transaction should be created but balance unchanged
|
|
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
const transactions = await Transaction.find({ user: userId });
|
|
|
|
|
|
expect(transactions).toHaveLength(1);
|
|
|
|
|
|
expect(transactions[0].inputTokens).toBe(-10);
|
|
|
|
|
|
expect(transactions[0].writeTokens).toBe(-100);
|
|
|
|
|
|
expect(transactions[0].readTokens).toBe(-5);
|
|
|
|
|
|
const balance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(balance.tokenCredits).toBe(initialBalance);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-02-06 18:35:36 -05:00
|
|
|
|
|
|
|
|
|
|
describe('calculateTokenValue Edge Cases', () => {
|
|
|
|
|
|
test('should derive multiplier from model when valueKey is not provided', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gpt-4';
|
|
|
|
|
|
const promptTokens = 1000;
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createTransaction({
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-no-valuekey',
|
|
|
|
|
|
model,
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
rawAmount: -promptTokens,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const expectedRate = getMultiplier({ model, tokenType: 'prompt' });
|
|
|
|
|
|
expect(result.rate).toBe(expectedRate);
|
|
|
|
|
|
|
|
|
|
|
|
const tx = await Transaction.findOne({ user: userId });
|
|
|
|
|
|
expect(tx.tokenValue).toBe(-promptTokens * expectedRate);
|
|
|
|
|
|
expect(tx.rate).toBe(expectedRate);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should derive valueKey and apply correct rate for an unknown model with tokenType', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
await createTransaction({
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-unknown-model',
|
|
|
|
|
|
model: 'some-unrecognized-model-xyz',
|
|
|
|
|
|
tokenType: 'prompt',
|
|
|
|
|
|
rawAmount: -500,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const tx = await Transaction.findOne({ user: userId });
|
|
|
|
|
|
expect(tx.rate).toBeDefined();
|
|
|
|
|
|
expect(tx.rate).toBeGreaterThan(0);
|
|
|
|
|
|
expect(tx.tokenValue).toBe(tx.rawAmount * tx.rate);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('should correctly apply model-derived multiplier without valueKey for completion', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-opus-4-6';
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createTransaction({
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-completion-no-valuekey',
|
|
|
|
|
|
model,
|
|
|
|
|
|
tokenType: 'completion',
|
|
|
|
|
|
rawAmount: -completionTokens,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const expectedRate = getMultiplier({ model, tokenType: 'completion' });
|
|
|
|
|
|
expect(expectedRate).toBe(tokenValues[model].completion);
|
|
|
|
|
|
expect(result.rate).toBe(expectedRate);
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(
|
|
|
|
|
|
initialBalance - completionTokens * expectedRate,
|
|
|
|
|
|
0,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('Premium Token Pricing Integration Tests', () => {
|
|
|
|
|
|
test('spendTokens should apply standard pricing when prompt tokens are below premium threshold', 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 = 100000;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-premium-below',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = tokenValues[model].prompt;
|
|
|
|
|
|
const standardCompletionRate = tokenValues[model].completion;
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should apply premium pricing when prompt tokens exceed premium threshold', 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;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-premium-above',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
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('spendTokens should apply standard pricing at exactly the premium threshold', 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 = premiumTokenValues[model].threshold;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-premium-exact',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = tokenValues[model].prompt;
|
|
|
|
|
|
const standardCompletionRate = tokenValues[model].completion;
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendStructuredTokens should apply premium pricing when total input tokens exceed threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-opus-4-6';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-structured-premium',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 200000,
|
|
|
|
|
|
write: 10000,
|
|
|
|
|
|
read: 5000,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 1000,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const totalInput =
|
|
|
|
|
|
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
|
|
|
|
|
|
|
|
|
|
|
|
await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
const premiumPromptRate = premiumTokenValues[model].prompt;
|
|
|
|
|
|
const premiumCompletionRate = premiumTokenValues[model].completion;
|
|
|
|
|
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
|
|
|
|
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
|
|
|
|
|
|
|
|
|
|
|
const expectedPromptCost =
|
|
|
|
|
|
tokenUsage.promptTokens.input * premiumPromptRate +
|
|
|
|
|
|
tokenUsage.promptTokens.write * writeMultiplier +
|
|
|
|
|
|
tokenUsage.promptTokens.read * readMultiplier;
|
|
|
|
|
|
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
|
|
|
|
|
|
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(totalInput).toBeGreaterThan(premiumTokenValues[model].threshold);
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendStructuredTokens should apply standard pricing when total input tokens are below threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-opus-4-6';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-structured-standard',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 50000,
|
|
|
|
|
|
write: 10000,
|
|
|
|
|
|
read: 5000,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 1000,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const totalInput =
|
|
|
|
|
|
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
|
|
|
|
|
|
|
|
|
|
|
|
await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = tokenValues[model].prompt;
|
|
|
|
|
|
const standardCompletionRate = tokenValues[model].completion;
|
|
|
|
|
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
|
|
|
|
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
|
|
|
|
|
|
|
|
|
|
|
const expectedPromptCost =
|
|
|
|
|
|
tokenUsage.promptTokens.input * standardPromptRate +
|
|
|
|
|
|
tokenUsage.promptTokens.write * writeMultiplier +
|
|
|
|
|
|
tokenUsage.promptTokens.read * readMultiplier;
|
|
|
|
|
|
const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate;
|
|
|
|
|
|
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(totalInput).toBeLessThanOrEqual(premiumTokenValues[model].threshold);
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-20 16:21:32 -05:00
|
|
|
|
test('spendTokens should apply standard pricing for gemini-3.1-pro-preview below threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gemini-3.1-pro-preview';
|
|
|
|
|
|
const promptTokens = 100000;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-gemini31-below',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = tokenValues['gemini-3.1'].prompt;
|
|
|
|
|
|
const standardCompletionRate = tokenValues['gemini-3.1'].completion;
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should apply premium pricing for gemini-3.1-pro-preview above threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gemini-3.1-pro-preview';
|
|
|
|
|
|
const promptTokens = 250000;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-gemini31-above',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
|
|
|
|
|
|
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendTokens should apply standard pricing for gemini-3.1-pro-preview at exactly the threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gemini-3.1-pro-preview';
|
|
|
|
|
|
const promptTokens = premiumTokenValues['gemini-3.1'].threshold;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-gemini31-exact',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = tokenValues['gemini-3.1'].prompt;
|
|
|
|
|
|
const standardCompletionRate = tokenValues['gemini-3.1'].completion;
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('spendStructuredTokens should apply premium pricing for gemini-3.1 when total input exceeds threshold', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'gemini-3.1-pro-preview';
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-gemini31-structured-premium',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'message',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tokenUsage = {
|
|
|
|
|
|
promptTokens: {
|
|
|
|
|
|
input: 200000,
|
|
|
|
|
|
write: 10000,
|
|
|
|
|
|
read: 5000,
|
|
|
|
|
|
},
|
|
|
|
|
|
completionTokens: 1000,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const totalInput =
|
|
|
|
|
|
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
|
|
|
|
|
|
|
|
|
|
|
|
await spendStructuredTokens(txData, tokenUsage);
|
|
|
|
|
|
|
|
|
|
|
|
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
|
|
|
|
|
|
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
|
|
|
|
|
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
|
|
|
|
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
|
|
|
|
|
|
|
|
|
|
|
const expectedPromptCost =
|
|
|
|
|
|
tokenUsage.promptTokens.input * premiumPromptRate +
|
|
|
|
|
|
tokenUsage.promptTokens.write * writeMultiplier +
|
|
|
|
|
|
tokenUsage.promptTokens.read * readMultiplier;
|
|
|
|
|
|
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
|
|
|
|
|
|
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(totalInput).toBeGreaterThan(premiumTokenValues['gemini-3.1'].threshold);
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-06 18:35:36 -05:00
|
|
|
|
test('non-premium models should not be affected by inputTokenCount regardless of prompt size', async () => {
|
|
|
|
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
const initialBalance = 100000000;
|
|
|
|
|
|
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
|
|
|
|
|
|
|
|
|
|
|
const model = 'claude-opus-4-5';
|
|
|
|
|
|
const promptTokens = 300000;
|
|
|
|
|
|
const completionTokens = 500;
|
|
|
|
|
|
|
|
|
|
|
|
const txData = {
|
|
|
|
|
|
user: userId,
|
|
|
|
|
|
conversationId: 'test-no-premium',
|
|
|
|
|
|
model,
|
|
|
|
|
|
context: 'test',
|
|
|
|
|
|
endpointTokenConfig: null,
|
|
|
|
|
|
balance: { enabled: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await spendTokens(txData, { promptTokens, completionTokens });
|
|
|
|
|
|
|
|
|
|
|
|
const standardPromptRate = getMultiplier({ model, tokenType: 'prompt' });
|
|
|
|
|
|
const standardCompletionRate = getMultiplier({ model, tokenType: 'completion' });
|
|
|
|
|
|
const expectedCost =
|
|
|
|
|
|
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBalance = await Balance.findOne({ user: userId });
|
|
|
|
|
|
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
🧮 refactor: Bulk Transactions & Balance Updates for Token Spending (#11996)
* refactor: transaction handling by integrating pricing and bulk write operations
- Updated `recordCollectedUsage` to accept pricing functions and bulk write operations, improving transaction management.
- Refactored `AgentClient` and related controllers to utilize the new transaction handling capabilities, ensuring better performance and accuracy in token spending.
- Added tests to validate the new functionality, ensuring correct behavior for both standard and bulk transaction paths.
- Introduced a new `transactions.ts` file to encapsulate transaction-related logic and types, enhancing code organization and maintainability.
* chore: reorganize imports in agents client controller
- Moved `getMultiplier` and `getCacheMultiplier` imports to maintain consistency and clarity in the import structure.
- Removed duplicate import of `updateBalance` and `bulkInsertTransactions`, streamlining the code for better readability.
* refactor: add TransactionData type and CANCEL_RATE constant to data-schemas
Establishes a single source of truth for the transaction document shape
and the incomplete-context billing rate constant, both consumed by
packages/api and api/.
* refactor: use proper types in data-schemas transaction methods
- Replace `as unknown as { tokenCredits }` with `lean<IBalance>()`
- Use `TransactionData[]` instead of `Record<string, unknown>[]`
for bulkInsertTransactions parameter
- Add JSDoc noting insertMany bypasses document middleware
- Remove orphan section comment in methods/index.ts
* refactor: use shared types in transactions.ts, fix bulk write logic
- Import CANCEL_RATE from data-schemas instead of local duplicate
- Import TransactionData from data-schemas for PreparedEntry/BulkWriteDeps
- Use tilde alias for EndpointTokenConfig import
- Pass valueKey through to getMultiplier
- Only sum tokenValue for balance-enabled docs in bulkWriteTransactions
- Consolidate two loops into single-pass map
* refactor: remove duplicate updateBalance from Transaction.js
Import updateBalance from ~/models (sourced from data-schemas) instead
of maintaining a second copy. Also import CANCEL_RATE from data-schemas
and remove the Balance model import (no longer needed directly).
* fix: test real spendCollectedUsage instead of IIFE replica
Export spendCollectedUsage from abortMiddleware.js and rewrite the test
file to import and test the actual function. Previously the tests ran
against a hand-written replica that could silently diverge from the real
implementation.
* test: add transactions.spec.ts and restore regression comments
Add 22 direct unit tests for transactions.ts financial logic covering
prepareTokenSpend, prepareStructuredTokenSpend, bulkWriteTransactions,
CANCEL_RATE paths, NaN guards, disabled transactions, zero tokens,
cache multipliers, and balance-enabled filtering.
Restore critical regression documentation comments in
recordCollectedUsage.spec.js explaining which production bugs the
tests guard against.
* fix: widen setValues type to include lastRefill
The UpdateBalanceParams.setValues type was Partial<Pick<IBalance,
'tokenCredits'>> which excluded lastRefill — used by
createAutoRefillTransaction. Widen to also pick 'lastRefill'.
* test: use real MongoDB for bulkWriteTransactions tests
Replace mock-based bulkWriteTransactions tests with real DB tests using
MongoMemoryServer. Pure function tests (prepareTokenSpend,
prepareStructuredTokenSpend) remain mock-based since they don't touch
DB. Add end-to-end integration tests that verify the full prepare →
bulk write → DB state pipeline with real Transaction and Balance models.
* chore: update @librechat/agents dependency to version 3.1.54 in package-lock.json and related package.json files
* test: add bulk path parity tests proving identical DB outcomes
Three test suites proving the bulk path (prepareTokenSpend/
prepareStructuredTokenSpend + bulkWriteTransactions) produces
numerically identical results to the legacy path for all scenarios:
- usage.bulk-parity.spec.ts: mirrors all legacy recordCollectedUsage
tests; asserts same return values and verifies metadata fields on
the insertMany docs match what spendTokens args would carry
- transactions.bulk-parity.spec.ts: real-DB tests using actual
getMultiplier/getCacheMultiplier pricing functions; asserts exact
tokenValue, rate, rawAmount and balance deductions for standard
tokens, structured/cache tokens, CANCEL_RATE, premium pricing,
multi-entry batches, and edge cases (NaN, zero, disabled)
- Transaction.spec.js: adds describe('Bulk path parity') that mirrors
7 key legacy tests via recordCollectedUsage + bulk deps against
real MongoDB, asserting same balance deductions and doc counts
* refactor: update llmConfig structure to use modelKwargs for reasoning effort
Refactor the llmConfig in getOpenAILLMConfig to store reasoning effort within modelKwargs instead of directly on llmConfig. This change ensures consistency in the configuration structure and improves clarity in the handling of reasoning properties in the tests.
* test: update performance checks in processAssistantMessage tests
Revise the performance assertions in the processAssistantMessage tests to ensure that each message processing time remains under 100ms, addressing potential ReDoS vulnerabilities. This change enhances the reliability of the tests by focusing on maximum processing time rather than relative ratios.
* test: fill parity test gaps — model fallback, abort context, structured edge cases
- usage.bulk-parity: add undefined model fallback test
- transactions.bulk-parity: add abort context test (txns inserted,
balance unchanged when balance not passed), fix readTokens type cast
- Transaction.spec: add 3 missing mirrors — balance disabled with
transactions enabled, structured transactions disabled, structured
balance disabled
* fix: deduct balance before inserting transactions to prevent orphaned docs
Swap the order in bulkWriteTransactions: updateBalance runs before
insertMany. If updateBalance fails (after exhausting retries), no
transaction documents are written — avoiding the inconsistent state
where transactions exist in MongoDB with no corresponding balance
deduction.
* chore: import order
* test: update config.spec.ts for OpenRouter reasoning in modelKwargs
Same fix as llm.spec.ts — OpenRouter reasoning is now passed via
modelKwargs instead of llmConfig.reasoning directly.
2026-03-01 12:26:36 -05:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|