Compare commits
20 Commits
main
...
feat/price
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b034624690 | ||
|
|
adff605c50 | ||
|
|
465c81adee | ||
|
|
cb8e76e27e | ||
|
|
4d9e17efe1 | ||
|
|
95ebef13df | ||
|
|
4fb9d7bdff | ||
|
|
0edfecf44a | ||
|
|
ba8c09b361 | ||
|
|
794fe6fd11 | ||
|
|
97ac52fc6c | ||
|
|
1a947607a5 | ||
|
|
1745708418 | ||
|
|
14aedac1e1 | ||
|
|
a820d79bfc | ||
|
|
3b1c07ff46 | ||
|
|
c1b0f13360 | ||
|
|
637bbd2e29 | ||
|
|
30e1b421ba | ||
|
|
fb89f60470 |
@@ -233,6 +233,7 @@ class BaseClient {
|
||||
sender: 'User',
|
||||
text,
|
||||
isCreatedByUser: true,
|
||||
targetModel: this.modelOptions?.model ?? this.model,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,17 @@ module.exports = {
|
||||
update.expiredAt = null;
|
||||
}
|
||||
|
||||
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
|
||||
/** @type {{ $set: Partial<TConversation>; $addToSet?: Record<string, any>; $unset?: Record<keyof TConversation, number> }} */
|
||||
const updateOperation = { $set: update };
|
||||
|
||||
if (convo.model && convo.endpoint) {
|
||||
updateOperation.$addToSet = {
|
||||
modelHistory: {
|
||||
model: convo.model,
|
||||
endpoint: convo.endpoint,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
|
||||
updateOperation.$unset = metadata.unsetFields;
|
||||
}
|
||||
|
||||
680
api/server/routes/__tests__/costs.spec.js
Normal file
680
api/server/routes/__tests__/costs.spec.js
Normal file
@@ -0,0 +1,680 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
createMethods: jest.fn(() => ({})),
|
||||
createModels: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: (req, res, next) => next(),
|
||||
validateMessageReq: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getConvo: jest.fn(),
|
||||
saveConvo: jest.fn(),
|
||||
saveMessage: jest.fn(),
|
||||
getMessage: jest.fn(),
|
||||
getMessages: jest.fn(),
|
||||
updateMessage: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/db/models', () => {
|
||||
let User, Message, Transaction, Conversation;
|
||||
|
||||
return {
|
||||
get User() {
|
||||
return User;
|
||||
},
|
||||
get Message() {
|
||||
return Message;
|
||||
},
|
||||
get Transaction() {
|
||||
return Transaction;
|
||||
},
|
||||
get Conversation() {
|
||||
return Conversation;
|
||||
},
|
||||
setUser: (model) => {
|
||||
User = model;
|
||||
},
|
||||
setMessage: (model) => {
|
||||
Message = model;
|
||||
},
|
||||
setTransaction: (model) => {
|
||||
Transaction = model;
|
||||
},
|
||||
setConversation: (model) => {
|
||||
Conversation = model;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('Costs Endpoint', () => {
|
||||
let app;
|
||||
let mongoServer;
|
||||
let messagesRouter;
|
||||
let User, Message, Transaction, Conversation;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
_id: String,
|
||||
name: String,
|
||||
email: String,
|
||||
});
|
||||
|
||||
const conversationSchema = new mongoose.Schema({
|
||||
conversationId: String,
|
||||
user: String,
|
||||
title: String,
|
||||
createdAt: Date,
|
||||
});
|
||||
|
||||
const messageSchema = new mongoose.Schema({
|
||||
messageId: String,
|
||||
conversationId: String,
|
||||
user: String,
|
||||
isCreatedByUser: Boolean,
|
||||
tokenCount: Number,
|
||||
createdAt: Date,
|
||||
});
|
||||
|
||||
const transactionSchema = new mongoose.Schema({
|
||||
conversationId: String,
|
||||
user: String,
|
||||
tokenType: String,
|
||||
tokenValue: Number,
|
||||
createdAt: Date,
|
||||
});
|
||||
|
||||
User = mongoose.model('User', userSchema);
|
||||
Conversation = mongoose.model('Conversation', conversationSchema);
|
||||
Message = mongoose.model('Message', messageSchema);
|
||||
Transaction = mongoose.model('Transaction', transactionSchema);
|
||||
|
||||
const dbModels = require('~/db/models');
|
||||
dbModels.setUser(User);
|
||||
dbModels.setMessage(Message);
|
||||
dbModels.setTransaction(Transaction);
|
||||
dbModels.setConversation(Conversation);
|
||||
|
||||
require('~/db/models');
|
||||
|
||||
try {
|
||||
messagesRouter = require('../messages');
|
||||
} catch (error) {
|
||||
console.error('Error loading messages router:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
req.user = { id: 'test-user-id' };
|
||||
next();
|
||||
});
|
||||
app.use('/api/messages', messagesRouter);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await User.deleteMany({});
|
||||
await Conversation.deleteMany({});
|
||||
await Message.deleteMany({});
|
||||
await Transaction.deleteMany({});
|
||||
});
|
||||
|
||||
describe('GET /:conversationId/costs', () => {
|
||||
const conversationId = 'test-conversation-123';
|
||||
const userId = 'test-user-id';
|
||||
|
||||
it('should return cost data for valid conversation', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
const aiMessage = new Message({
|
||||
messageId: 'ai-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: false,
|
||||
tokenCount: 150,
|
||||
createdAt: new Date('2024-01-01T10:01:00Z'),
|
||||
});
|
||||
|
||||
await Promise.all([userMessage.save(), aiMessage.save()]);
|
||||
|
||||
const promptTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 500000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
const completionTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'completion',
|
||||
tokenValue: 750000,
|
||||
createdAt: new Date('2024-01-01T10:01:30Z'),
|
||||
});
|
||||
|
||||
await Promise.all([promptTransaction.save(), completionTransaction.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
conversationId,
|
||||
totals: {
|
||||
prompt: { usd: 0.5, tokenCount: 100 },
|
||||
completion: { usd: 0.75, tokenCount: 150 },
|
||||
total: { usd: 1.25, tokenCount: 250 },
|
||||
},
|
||||
perMessage: [
|
||||
{ messageId: 'user-msg-1', tokenType: 'prompt', tokenCount: 100, usd: 0.5 },
|
||||
{ messageId: 'ai-msg-1', tokenType: 'completion', tokenCount: 150, usd: 0.75 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty data for conversation with no messages', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
conversationId,
|
||||
totals: {
|
||||
prompt: { usd: 0, tokenCount: 0 },
|
||||
completion: { usd: 0, tokenCount: 0 },
|
||||
total: { usd: 0, tokenCount: 0 },
|
||||
},
|
||||
perMessage: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle messages without transactions', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
const aiMessage = new Message({
|
||||
messageId: 'ai-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: false,
|
||||
tokenCount: 150,
|
||||
createdAt: new Date('2024-01-01T10:01:00Z'),
|
||||
});
|
||||
|
||||
await Promise.all([userMessage.save(), aiMessage.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0);
|
||||
expect(response.body.totals.completion.usd).toBe(0);
|
||||
expect(response.body.totals.total.usd).toBe(0);
|
||||
});
|
||||
|
||||
it('should aggregate multiple transactions correctly', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await userMessage.save();
|
||||
|
||||
const promptTransaction1 = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 300000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
const promptTransaction2 = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 200000,
|
||||
createdAt: new Date('2024-01-01T10:00:45Z'),
|
||||
});
|
||||
|
||||
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0.5);
|
||||
expect(response.body.perMessage[0].usd).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should handle null tokenCount values', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: null,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await userMessage.save();
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.tokenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null tokenValue in transactions', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await userMessage.save();
|
||||
|
||||
const promptTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: null,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
await promptTransaction.save();
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle negative tokenValue using Math.abs', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await userMessage.save();
|
||||
|
||||
const promptTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: -500000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
await promptTransaction.save();
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should filter by user correctly', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const otherUserId = 'other-user-id';
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
const otherUserMessage = new Message({
|
||||
messageId: 'other-user-msg-1',
|
||||
conversationId,
|
||||
user: otherUserId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 200,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await Promise.all([userMessage.save(), otherUserMessage.save()]);
|
||||
|
||||
const userTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 500000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
const otherUserTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: otherUserId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 1000000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
await Promise.all([userTransaction.save(), otherUserTransaction.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0.5);
|
||||
expect(response.body.perMessage).toHaveLength(1);
|
||||
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
|
||||
});
|
||||
|
||||
it('should filter transactions by tokenType', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
await userMessage.save();
|
||||
|
||||
const promptTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 500000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
const otherTransaction = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'other',
|
||||
tokenValue: 1000000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
await Promise.all([promptTransaction.save(), otherTransaction.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totals.prompt.usd).toBe(0.5);
|
||||
expect(response.body.totals.completion.usd).toBe(0);
|
||||
expect(response.body.totals.total.usd).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should map transactions to messages chronologically', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
const userMessage1 = new Message({
|
||||
messageId: 'user-msg-1',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 100,
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
});
|
||||
|
||||
const userMessage2 = new Message({
|
||||
messageId: 'user-msg-2',
|
||||
conversationId,
|
||||
user: userId,
|
||||
isCreatedByUser: true,
|
||||
tokenCount: 200,
|
||||
createdAt: new Date('2024-01-01T10:01:00Z'),
|
||||
});
|
||||
|
||||
await Promise.all([userMessage1.save(), userMessage2.save()]);
|
||||
|
||||
const promptTransaction1 = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 500000,
|
||||
createdAt: new Date('2024-01-01T10:00:30Z'),
|
||||
});
|
||||
|
||||
const promptTransaction2 = new Transaction({
|
||||
conversationId,
|
||||
user: userId,
|
||||
tokenType: 'prompt',
|
||||
tokenValue: 1000000,
|
||||
createdAt: new Date('2024-01-01T10:01:30Z'),
|
||||
});
|
||||
|
||||
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.perMessage).toHaveLength(2);
|
||||
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
|
||||
expect(response.body.perMessage[0].usd).toBe(0.5);
|
||||
expect(response.body.perMessage[1].messageId).toBe('user-msg-2');
|
||||
expect(response.body.perMessage[1].usd).toBe(1.0);
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
getConvo.mockResolvedValue({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
});
|
||||
|
||||
const conversation = new Conversation({
|
||||
conversationId,
|
||||
user: userId,
|
||||
title: 'Test Conversation',
|
||||
createdAt: new Date('2024-01-01T09:00:00Z'),
|
||||
});
|
||||
|
||||
await conversation.save();
|
||||
|
||||
await mongoose.connection.close();
|
||||
|
||||
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
} = require('~/models');
|
||||
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
|
||||
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
||||
const { tokenValues, getValueKey, defaultRate } = require('~/models/tx');
|
||||
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
||||
const { getConvosQueried } = require('~/models/Conversation');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
@@ -160,6 +161,41 @@ router.post('/artifact/:messageId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /costs
|
||||
* Get cost information for models in modelHistory array
|
||||
*/
|
||||
router.post('/costs', async (req, res) => {
|
||||
try {
|
||||
const { modelHistory } = req.body;
|
||||
|
||||
if (!Array.isArray(modelHistory)) {
|
||||
return res.status(400).json({ error: 'modelHistory must be an array' });
|
||||
}
|
||||
|
||||
const modelCostTable = {};
|
||||
|
||||
modelHistory.forEach((modelEntry) => {
|
||||
if (modelEntry && typeof modelEntry === 'object' && modelEntry.model && modelEntry.endpoint) {
|
||||
const { model, endpoint } = modelEntry;
|
||||
|
||||
const valueKey = getValueKey(model, endpoint);
|
||||
const pricing = tokenValues[valueKey];
|
||||
|
||||
modelCostTable[model] = {
|
||||
prompt: pricing?.prompt ?? defaultRate,
|
||||
completion: pricing?.completion ?? defaultRate,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({ modelCostTable });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching model costs:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
|
||||
router.get('/:conversationId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -88,6 +88,7 @@ async function saveUserMessage(req, params) {
|
||||
parentMessageId: params.parentMessageId ?? Constants.NO_PARENT,
|
||||
/* For messages, use the assistant_id instead of model */
|
||||
model: params.assistant_id,
|
||||
targetModel: params.model,
|
||||
thread_id: params.thread_id,
|
||||
sender: 'User',
|
||||
text: params.text,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Spinner } from '@librechat/client';
|
||||
@@ -13,6 +13,7 @@ import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import Presentation from './Presentation';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import CostBar from './CostBar';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
@@ -29,7 +30,13 @@ function LoadingSpinner() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatView({ index = 0 }: { index?: number }) {
|
||||
function ChatView({
|
||||
index = 0,
|
||||
modelCosts,
|
||||
}: {
|
||||
index?: number;
|
||||
modelCosts?: { modelCostTable: Record<string, { prompt: number; completion: number }> };
|
||||
}) {
|
||||
const { conversationId } = useParams();
|
||||
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
||||
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
|
||||
@@ -37,6 +44,9 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||
|
||||
const fileMap = useFileMapContext();
|
||||
|
||||
const [showCostBar, setShowCostBar] = useState(false);
|
||||
const lastScrollY = useRef(0);
|
||||
|
||||
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
|
||||
select: useCallback(
|
||||
(data: TMessage[]) => {
|
||||
@@ -54,6 +64,58 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||
useSSE(rootSubmission, chatHelpers, false);
|
||||
useSSE(addedSubmission, addedChatHelpers, true);
|
||||
|
||||
const checkIfAtBottom = useCallback(
|
||||
(container: HTMLElement) => {
|
||||
const currentScrollY = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
const distanceFromBottom = scrollHeight - currentScrollY - clientHeight;
|
||||
const isAtBottom = distanceFromBottom < 10;
|
||||
|
||||
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
|
||||
setShowCostBar(isAtBottom && !isStreaming);
|
||||
lastScrollY.current = currentScrollY;
|
||||
},
|
||||
[chatHelpers.isSubmitting, addedChatHelpers.isSubmitting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
checkIfAtBottom(target);
|
||||
};
|
||||
|
||||
const findAndAttachScrollListener = () => {
|
||||
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
|
||||
if (messagesContainer) {
|
||||
checkIfAtBottom(messagesContainer as HTMLElement);
|
||||
|
||||
messagesContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
messagesContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
setTimeout(findAndAttachScrollListener, 100);
|
||||
};
|
||||
|
||||
const cleanup = findAndAttachScrollListener();
|
||||
|
||||
return cleanup;
|
||||
}, [messagesTree, checkIfAtBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
|
||||
if (isStreaming) {
|
||||
setShowCostBar(false);
|
||||
} else {
|
||||
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
|
||||
if (messagesContainer) {
|
||||
checkIfAtBottom(messagesContainer as HTMLElement);
|
||||
}
|
||||
}
|
||||
}, [chatHelpers.isSubmitting, addedChatHelpers.isSubmitting, checkIfAtBottom]);
|
||||
|
||||
const methods = useForm<ChatFormValues>({
|
||||
defaultValues: { text: '' },
|
||||
});
|
||||
@@ -69,7 +131,22 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||
} else if ((isLoading || isNavigating) && !isLandingPage) {
|
||||
content = <LoadingSpinner />;
|
||||
} else if (!isLandingPage) {
|
||||
content = <MessagesView messagesTree={messagesTree} />;
|
||||
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
|
||||
content = (
|
||||
<MessagesView
|
||||
messagesTree={messagesTree}
|
||||
costBar={
|
||||
!isLandingPage &&
|
||||
modelCosts && (
|
||||
<CostBar
|
||||
messagesTree={messagesTree}
|
||||
modelCosts={modelCosts}
|
||||
showCostBar={showCostBar && !isStreaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
|
||||
}
|
||||
|
||||
112
client/src/components/Chat/CostBar.tsx
Normal file
112
client/src/components/Chat/CostBar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ArrowIcon } from '@librechat/client';
|
||||
import { TModelCosts, TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface CostBarProps {
|
||||
messagesTree: TMessage[];
|
||||
modelCosts: TModelCosts;
|
||||
showCostBar: boolean;
|
||||
}
|
||||
|
||||
export default function CostBar({ messagesTree, modelCosts, showCostBar }: CostBarProps) {
|
||||
const localize = useLocalize();
|
||||
const showCostTracking = useRecoilValue(store.showCostTracking);
|
||||
|
||||
const conversationCosts = useMemo(() => {
|
||||
if (!modelCosts?.modelCostTable || !messagesTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let totalPromptTokens = 0;
|
||||
let totalCompletionTokens = 0;
|
||||
let totalPromptUSD = 0;
|
||||
let totalCompletionUSD = 0;
|
||||
|
||||
const flattenMessages = (messages: TMessage[]) => {
|
||||
const flattened: TMessage[] = [];
|
||||
messages.forEach((message: TMessage) => {
|
||||
flattened.push(message);
|
||||
if (message.children && message.children.length > 0) {
|
||||
flattened.push(...flattenMessages(message.children));
|
||||
}
|
||||
});
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const allMessages = flattenMessages(messagesTree);
|
||||
|
||||
allMessages.forEach((message) => {
|
||||
if (!message.tokenCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelToUse = message.isCreatedByUser ? message.targetModel : message.model;
|
||||
|
||||
const modelPricing = modelCosts.modelCostTable[modelToUse];
|
||||
if (message.isCreatedByUser) {
|
||||
totalPromptTokens += message.tokenCount;
|
||||
totalPromptUSD += (message.tokenCount / 1000000) * modelPricing.prompt;
|
||||
} else {
|
||||
totalCompletionTokens += message.tokenCount;
|
||||
totalCompletionUSD += (message.tokenCount / 1000000) * modelPricing.completion;
|
||||
}
|
||||
});
|
||||
|
||||
const totalTokens = totalPromptTokens + totalCompletionTokens;
|
||||
const totalUSD = totalPromptUSD + totalCompletionUSD;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
prompt: { tokenCount: totalPromptTokens, usd: totalPromptUSD },
|
||||
completion: { tokenCount: totalCompletionTokens, usd: totalCompletionUSD },
|
||||
total: { tokenCount: totalTokens, usd: totalUSD },
|
||||
},
|
||||
};
|
||||
}, [modelCosts, messagesTree]);
|
||||
|
||||
if (!showCostTracking || !conversationCosts || !conversationCosts.totals) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto w-full max-w-md px-4 text-xs text-muted-foreground transition-all duration-300 ease-in-out',
|
||||
showCostBar ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div>
|
||||
<ArrowIcon direction="up" />
|
||||
{localize('com_ui_token_abbreviation', {
|
||||
0: conversationCosts.totals.prompt.tokenCount,
|
||||
})}
|
||||
</div>
|
||||
<div>${Math.abs(conversationCosts.totals.prompt.usd).toFixed(6)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{localize('com_ui_token_abbreviation', {
|
||||
0: conversationCosts.totals.total.tokenCount,
|
||||
})}
|
||||
</div>
|
||||
<div>${Math.abs(conversationCosts.totals.total.usd).toFixed(6)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<ArrowIcon direction="down" />
|
||||
{localize('com_ui_token_abbreviation', {
|
||||
0: conversationCosts.totals.completion.tokenCount,
|
||||
})}
|
||||
</div>
|
||||
<div>${Math.abs(conversationCosts.totals.completion.usd).toFixed(6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageRender from './ui/MessageRender';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
@@ -28,7 +29,7 @@ const MessageContainer = React.memo(
|
||||
},
|
||||
);
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
|
||||
const {
|
||||
showSibling,
|
||||
conversation,
|
||||
@@ -37,7 +38,7 @@ export default function Message(props: TMessageProps) {
|
||||
latestMultiMessage,
|
||||
isSubmittingFamily,
|
||||
} = useMessageProcess({ message: props.message });
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
const { message, currentEditId, setCurrentEditId, costs } = props;
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
@@ -62,6 +63,7 @@ export default function Message(props: TMessageProps) {
|
||||
message={message}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
isCard
|
||||
costs={costs}
|
||||
/>
|
||||
<MessageRender
|
||||
{...props}
|
||||
@@ -69,12 +71,13 @@ export default function Message(props: TMessageProps) {
|
||||
isCard
|
||||
message={siblingMessage ?? latestMultiMessage ?? undefined}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
costs={costs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<MessageRender {...props} />
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<MessageRender {...props} costs={costs} />
|
||||
</div>
|
||||
)}
|
||||
</MessageContainer>
|
||||
@@ -85,6 +88,7 @@ export default function Message(props: TMessageProps) {
|
||||
messagesTree={children ?? []}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
costs={costs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
@@ -12,10 +12,17 @@ import SubRow from './SubRow';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
|
||||
const localize = useLocalize();
|
||||
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
|
||||
props;
|
||||
const {
|
||||
message,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
costs,
|
||||
} = props;
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: message?.messageId,
|
||||
attachments: message?.attachments,
|
||||
@@ -164,6 +171,7 @@ export default function Message(props: TMessageProps) {
|
||||
messagesTree={children ?? []}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
costs={costs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
|
||||
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import MultiMessage from './MultiMessage';
|
||||
@@ -10,8 +10,12 @@ import store from '~/store';
|
||||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
costBar,
|
||||
costs,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
costBar?: React.ReactNode;
|
||||
costs?: TConversationCosts;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
@@ -44,7 +48,7 @@ export default function MessagesView({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col pb-9 dark:bg-transparent">
|
||||
<div className="flex flex-col dark:bg-transparent">
|
||||
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -63,18 +67,25 @@ export default function MessagesView({
|
||||
messageId={conversationId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
costs={costs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
id="messages-end"
|
||||
className="group h-0 w-full flex-shrink-0"
|
||||
className="group h-1 w-full flex-shrink-0 pb-7"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{costBar && (
|
||||
<div className="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2">
|
||||
{costBar}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CSSTransition
|
||||
in={showScrollButton && scrollButtonPreference}
|
||||
timeout={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageContent from '~/components/Messages/MessageContent';
|
||||
import MessageParts from './MessageParts';
|
||||
@@ -14,7 +14,8 @@ export default function MultiMessage({
|
||||
messagesTree,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
}: TMessageProps) {
|
||||
costs,
|
||||
}: TMessageProps & { costs?: TConversationCosts }) {
|
||||
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
|
||||
|
||||
const setSiblingIdxRev = useCallback(
|
||||
@@ -55,6 +56,7 @@ export default function MultiMessage({
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
costs={costs}
|
||||
/>
|
||||
);
|
||||
} else if (message.content) {
|
||||
@@ -67,6 +69,7 @@ export default function MultiMessage({
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
costs={costs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +83,7 @@ export default function MultiMessage({
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
costs={costs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type TMessage } from 'librechat-data-provider';
|
||||
import { ArrowIcon } from '@librechat/client';
|
||||
import { type TMessage, TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useMessageActions, useLocalize } from '~/hooks';
|
||||
import { Plugin } from '~/components/Messages/Content';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import { useMessageActions } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -19,6 +20,7 @@ type MessageRenderProps = {
|
||||
isCard?: boolean;
|
||||
isMultiMessage?: boolean;
|
||||
isSubmittingFamily?: boolean;
|
||||
costs?: TConversationCosts;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
@@ -35,7 +37,9 @@ const MessageRender = memo(
|
||||
isMultiMessage = false,
|
||||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
costs,
|
||||
}: MessageRenderProps) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
ask,
|
||||
edit,
|
||||
@@ -60,6 +64,18 @@ const MessageRender = memo(
|
||||
});
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
const showCostTracking = useRecoilValue(store.showCostTracking);
|
||||
|
||||
const perMessageCost = useMemo(() => {
|
||||
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
|
||||
return null;
|
||||
}
|
||||
const entry = costs.perMessage.find((p) => p.messageId === msg.messageId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}, [showCostTracking, costs, msg?.messageId]);
|
||||
|
||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||
@@ -157,7 +173,26 @@ const MessageRender = memo(
|
||||
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
{messageLabel}
|
||||
{perMessageCost && (
|
||||
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{perMessageCost.tokenCount > 0 && (
|
||||
<span>
|
||||
{perMessageCost.tokenType === 'prompt' ? (
|
||||
<ArrowIcon direction="up" className="inline" />
|
||||
) : (
|
||||
<ArrowIcon direction="down" className="inline" />
|
||||
)}
|
||||
{localize('com_ui_token_abbreviation', {
|
||||
0: perMessageCost.tokenCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ArrowIcon } from '@librechat/client';
|
||||
import { useCallback, useMemo, memo } from 'react';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessage, TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import { useAttachments, useMessageActions, useLocalize } from '~/hooks';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useAttachments, useMessageActions } from '~/hooks';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
@@ -17,6 +18,7 @@ type ContentRenderProps = {
|
||||
isCard?: boolean;
|
||||
isMultiMessage?: boolean;
|
||||
isSubmittingFamily?: boolean;
|
||||
costs?: TConversationCosts;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
@@ -33,7 +35,9 @@ const ContentRender = memo(
|
||||
isMultiMessage = false,
|
||||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
costs,
|
||||
}: ContentRenderProps) => {
|
||||
const localize = useLocalize();
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: msg?.messageId,
|
||||
attachments: msg?.attachments,
|
||||
@@ -62,6 +66,14 @@ const ContentRender = memo(
|
||||
});
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
const showCostTracking = useRecoilValue(store.showCostTracking);
|
||||
|
||||
const perMessageCost = useMemo(() => {
|
||||
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
|
||||
return null;
|
||||
}
|
||||
return costs.perMessage.find((p) => p.messageId === msg.messageId) ?? null;
|
||||
}, [showCostTracking, costs, msg?.messageId]);
|
||||
|
||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||
const isLast = useMemo(
|
||||
@@ -159,7 +171,26 @@ const ContentRender = memo(
|
||||
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
{messageLabel}
|
||||
{perMessageCost && (
|
||||
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{perMessageCost.tokenCount > 0 && (
|
||||
<span className="mr-2">
|
||||
{perMessageCost.tokenType === 'prompt' ? (
|
||||
<ArrowIcon direction="up" className="inline" />
|
||||
) : (
|
||||
<ArrowIcon direction="down" className="inline" />
|
||||
)}
|
||||
{localize('com_ui_token_abbreviation', {
|
||||
0: perMessageCost.tokenCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TConversationCosts } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
|
||||
@@ -25,7 +26,7 @@ const MessageContainer = React.memo(
|
||||
},
|
||||
);
|
||||
|
||||
export default function MessageContent(props: TMessageProps) {
|
||||
export default function MessageContent(props: TMessageProps & { costs?: TConversationCosts }) {
|
||||
const {
|
||||
showSibling,
|
||||
conversation,
|
||||
@@ -34,7 +35,7 @@ export default function MessageContent(props: TMessageProps) {
|
||||
latestMultiMessage,
|
||||
isSubmittingFamily,
|
||||
} = useMessageProcess({ message: props.message });
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
const { message, currentEditId, setCurrentEditId, costs } = props;
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null;
|
||||
@@ -53,6 +54,7 @@ export default function MessageContent(props: TMessageProps) {
|
||||
message={message}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
isCard
|
||||
costs={costs}
|
||||
/>
|
||||
<ContentRender
|
||||
{...props}
|
||||
@@ -60,12 +62,13 @@ export default function MessageContent(props: TMessageProps) {
|
||||
isCard
|
||||
message={siblingMessage ?? latestMultiMessage ?? undefined}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
costs={costs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<ContentRender {...props} />
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<ContentRender {...props} costs={costs} />
|
||||
</div>
|
||||
)}
|
||||
</MessageContainer>
|
||||
@@ -76,6 +79,7 @@ export default function MessageContent(props: TMessageProps) {
|
||||
messagesTree={children ?? []}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
costs={costs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -76,6 +76,13 @@ const toggleSwitchConfigs = [
|
||||
hoverCardText: undefined,
|
||||
key: 'modularChat',
|
||||
},
|
||||
{
|
||||
stateAtom: store.showCostTracking,
|
||||
localizationKey: 'com_nav_show_cost_tracking',
|
||||
switchId: 'showCostTracking',
|
||||
hoverCardText: 'com_nav_info_show_cost_tracking',
|
||||
key: 'showCostTracking',
|
||||
},
|
||||
];
|
||||
|
||||
function Chat() {
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
const timeoutIdRef = useRef<NodeJS.Timeout>();
|
||||
const prevIsSubmittingRef = useRef<boolean>(false);
|
||||
|
||||
const debouncedSetShowScrollButton = useCallback((value: boolean) => {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
@@ -60,7 +61,10 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
|
||||
}
|
||||
}, [debouncedSetShowScrollButton]);
|
||||
|
||||
const scrollCallback = () => debouncedSetShowScrollButton(false);
|
||||
const scrollCallback = useCallback(
|
||||
() => debouncedSetShowScrollButton(false),
|
||||
[debouncedSetShowScrollButton],
|
||||
);
|
||||
|
||||
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
|
||||
targetRef: messagesEndRef,
|
||||
@@ -71,6 +75,18 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
|
||||
},
|
||||
});
|
||||
|
||||
const smoothScrollToBottom = useCallback(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
inline: 'nearest',
|
||||
});
|
||||
scrollCallback();
|
||||
setAbortScroll(false);
|
||||
}
|
||||
}, [scrollCallback, setAbortScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagesTree || messagesTree.length === 0) {
|
||||
return;
|
||||
@@ -91,6 +107,20 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
|
||||
};
|
||||
}, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagesEndRef.current || !scrollableRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevIsSubmittingRef.current && !isSubmitting && abortScroll !== true) {
|
||||
setTimeout(() => {
|
||||
smoothScrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
prevIsSubmittingRef.current = isSubmitting;
|
||||
}, [isSubmitting, smoothScrollToBottom, abortScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagesEndRef.current || !scrollableRef.current) {
|
||||
return;
|
||||
|
||||
@@ -232,8 +232,14 @@ export default function useEventHandlers({
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (userMessage?.conversationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.conversation, userMessage.conversationId, 'costs'],
|
||||
});
|
||||
}
|
||||
},
|
||||
[setMessages, announcePolite, setIsSubmitting],
|
||||
[setMessages, announcePolite, setIsSubmitting, queryClient],
|
||||
);
|
||||
|
||||
const cancelHandler = useCallback(
|
||||
@@ -275,6 +281,12 @@ export default function useEventHandlers({
|
||||
});
|
||||
}
|
||||
|
||||
if (convoUpdate?.conversationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.conversation, convoUpdate.conversationId, 'costs'],
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[setMessages, setConversation, genTitle, isAddedRequest, queryClient, setIsSubmitting],
|
||||
@@ -341,6 +353,12 @@ export default function useEventHandlers({
|
||||
if (resetLatestMessage) {
|
||||
resetLatestMessage();
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.conversation, conversationId, 'costs'],
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
@@ -527,6 +545,12 @@ export default function useEventHandlers({
|
||||
);
|
||||
}
|
||||
|
||||
if (conversation.conversationId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.conversation, conversation.conversationId, 'costs'],
|
||||
});
|
||||
}
|
||||
|
||||
if (isNewConvo && submissionConvo.conversationId) {
|
||||
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useScrollToRef({
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const scrollToRef = useCallback(
|
||||
throttle(() => logAndScroll('instant', callback), 145, { leading: true }),
|
||||
throttle(() => logAndScroll('instant', callback), 100, { leading: true }),
|
||||
[targetRef],
|
||||
);
|
||||
|
||||
|
||||
@@ -568,6 +568,8 @@
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
"com_nav_show_code": "Always show code when using code interpreter",
|
||||
"com_nav_show_cost_tracking": "Show cost tracking",
|
||||
"com_nav_info_show_cost_tracking": "Display conversation costs and per-message cost breakdowns",
|
||||
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
|
||||
"com_nav_slash_command": "/-Command",
|
||||
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
|
||||
@@ -1197,6 +1199,7 @@
|
||||
"com_ui_thinking": "Thinking...",
|
||||
"com_ui_thoughts": "Thoughts",
|
||||
"com_ui_token": "token",
|
||||
"com_ui_token_abbreviation": "{{0}}t",
|
||||
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||
"com_ui_token_url": "Token URL",
|
||||
"com_ui_tokens": "tokens",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useNewConvo, useAppStartup, useAssistantListMap, useIdChangeEffect } from '~/hooks';
|
||||
import { useGetModelCostsQuery } from 'librechat-data-provider/react-query';
|
||||
import { getDefaultModelSpec, getModelSpecPreset, logger } from '~/utils';
|
||||
import { ToolCallsMapProvider } from '~/Providers';
|
||||
import ChatView from '~/components/Chat/ChatView';
|
||||
@@ -44,6 +45,10 @@ export default function ChatRoute() {
|
||||
const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated });
|
||||
const assistantListMap = useAssistantListMap();
|
||||
|
||||
const modelCostsQuery = useGetModelCostsQuery(initialConvoQuery.data?.modelHistory || [], {
|
||||
enabled: !!initialConvoQuery.data?.modelHistory?.length,
|
||||
});
|
||||
|
||||
const isTemporaryChat = conversation && conversation.expiredAt ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,7 +153,7 @@ export default function ChatRoute() {
|
||||
|
||||
return (
|
||||
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
|
||||
<ChatView index={index} />
|
||||
<ChatView index={index} modelCosts={modelCostsQuery.data} />
|
||||
</ToolCallsMapProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const localStorageAtoms = {
|
||||
showCode: atomWithLocalStorage(LocalStorageKeys.SHOW_ANALYSIS_CODE, true),
|
||||
saveDrafts: atomWithLocalStorage('saveDrafts', true),
|
||||
showScrollButton: atomWithLocalStorage('showScrollButton', true),
|
||||
showCostTracking: atomWithLocalStorage('showCostTracking', true),
|
||||
forkSetting: atomWithLocalStorage('forkSetting', ''),
|
||||
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
|
||||
rememberDefaultFork: atomWithLocalStorage(LocalStorageKeys.REMEMBER_FORK_OPTION, false),
|
||||
|
||||
42
packages/client/src/svgs/ArrowIcon.tsx
Normal file
42
packages/client/src/svgs/ArrowIcon.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
interface ArrowIconProps {
|
||||
direction: 'up' | 'down';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ArrowIcon({ direction, className = 'inline' }: ArrowIconProps) {
|
||||
if (direction === 'up') {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.293 5.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1-1.414 1.414L13 8.414V18a1 1 0 1 1-2 0V8.414l-3.293 3.293a1 1 0 0 1-1.414-1.414l5-5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as ArchiveIcon } from './ArchiveIcon';
|
||||
export { default as ArrowIcon } from './ArrowIcon';
|
||||
export { default as Blocks } from './Blocks';
|
||||
export { default as Plugin } from './Plugin';
|
||||
export { default as GPTIcon } from './GPTIcon';
|
||||
|
||||
@@ -66,6 +66,8 @@ export const messages = (params: q.MessagesListParams) => {
|
||||
|
||||
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifacts/${messageId}`;
|
||||
|
||||
export const costs = () => `/api/messages/costs`;
|
||||
|
||||
const shareRoot = `${BASE_URL}/api/share`;
|
||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
|
||||
|
||||
@@ -51,6 +51,7 @@ export const excludedKeys = new Set([
|
||||
'_id',
|
||||
'tools',
|
||||
'model',
|
||||
'modelHistory',
|
||||
'files',
|
||||
'spec',
|
||||
'disableParams',
|
||||
|
||||
@@ -697,6 +697,12 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
|
||||
return request.get(endpoints.messages({ conversationId }));
|
||||
}
|
||||
|
||||
export function getModelCosts(
|
||||
modelHistory: Array<{ model: string; endpoint: string }>,
|
||||
): Promise<t.TModelCosts> {
|
||||
return request.post(endpoints.costs(), { modelHistory });
|
||||
}
|
||||
|
||||
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
|
||||
return request.get(endpoints.getPrompt(id));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum QueryKeys {
|
||||
archivedConversations = 'archivedConversations',
|
||||
searchConversations = 'searchConversations',
|
||||
conversation = 'conversation',
|
||||
modelCosts = 'modelCosts',
|
||||
searchEnabled = 'searchEnabled',
|
||||
user = 'user',
|
||||
name = 'name', // user key name
|
||||
|
||||
@@ -77,6 +77,23 @@ export const useGetConversationByIdQuery = (
|
||||
);
|
||||
};
|
||||
|
||||
export const useGetModelCostsQuery = (
|
||||
modelHistory: Array<{ model: string; endpoint: string }>,
|
||||
config?: UseQueryOptions<t.TModelCosts>,
|
||||
): QueryObserverResult<t.TModelCosts> => {
|
||||
return useQuery<t.TModelCosts>(
|
||||
[QueryKeys.modelCosts, modelHistory],
|
||||
() => dataService.getModelCosts(modelHistory),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
enabled: !!modelHistory && modelHistory.length > 0,
|
||||
...config,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
//This isn't ideal because its just a query and we're using mutation, but it was the only way
|
||||
//to make it work with how the Chat component is structured
|
||||
export const useGetConversationByIdMutation = (id: string): UseMutationResult<s.TConversation> => {
|
||||
|
||||
@@ -518,6 +518,7 @@ export const tMessageSchema = z.object({
|
||||
overrideParentMessageId: z.string().nullable().optional(),
|
||||
bg: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
targetModel: z.string().nullable().optional(),
|
||||
title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'),
|
||||
sender: z.string().optional(),
|
||||
text: z.string(),
|
||||
@@ -631,6 +632,7 @@ export const tConversationSchema = z.object({
|
||||
modelLabel: z.string().nullable().optional(),
|
||||
userLabel: z.string().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
modelHistory: z.array(z.object({ model: z.string(), endpoint: z.string() })).optional(),
|
||||
promptPrefix: z.string().nullable().optional(),
|
||||
temperature: z.number().nullable().optional(),
|
||||
topP: z.number().optional(),
|
||||
|
||||
@@ -653,3 +653,15 @@ export type TBalanceResponse = {
|
||||
lastRefill?: Date;
|
||||
refillAmount?: number;
|
||||
};
|
||||
|
||||
export type TConversationCosts = {
|
||||
totals: {
|
||||
prompt: { usd: number; tokenCount: number };
|
||||
completion: { usd: number; tokenCount: number };
|
||||
total: { usd: number; tokenCount: number };
|
||||
};
|
||||
};
|
||||
|
||||
export type TModelCosts = {
|
||||
modelCostTable: Record<string, { prompt: number; completion: number }>;
|
||||
};
|
||||
|
||||
@@ -155,4 +155,14 @@ export const conversationPreset = {
|
||||
verbosity: {
|
||||
type: String,
|
||||
},
|
||||
/** Track all unique models used in this conversation with their endpoints */
|
||||
modelHistory: {
|
||||
type: [
|
||||
{
|
||||
model: { type: String, required: true },
|
||||
endpoint: { type: String, required: true },
|
||||
},
|
||||
],
|
||||
default: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,10 @@ const messageSchema: Schema<IMessage> = new Schema(
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
targetModel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface IConversation extends Document {
|
||||
endpoint?: string;
|
||||
endpointType?: string;
|
||||
model?: string;
|
||||
modelHistory?: Array<{ model: string; endpoint: string }>;
|
||||
region?: string;
|
||||
chatGptLabel?: string;
|
||||
examples?: unknown[];
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface IMessage extends Document {
|
||||
conversationId: string;
|
||||
user: string;
|
||||
model?: string;
|
||||
targetModel?: string;
|
||||
endpoint?: string;
|
||||
conversationSignature?: string;
|
||||
clientId?: string;
|
||||
|
||||
Reference in New Issue
Block a user