feat: add time-based expense summaries - Add granularity support (daily/monthly/yearly) to stats endpoint - Implement time period grouping in expense calculations - Add proper date validation and error handling - Update API documentation with new parameters
This commit is contained in:
149
src/api/controller/stats.js
Normal file
149
src/api/controller/stats.js
Normal file
@ -0,0 +1,149 @@
|
||||
const Prompt = require('../../models/Prompt');
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/database');
|
||||
|
||||
/**
|
||||
* Calculate expense summaries per model with time granularity
|
||||
* @param {Object} [options] - Query options
|
||||
* @param {string} [options.startDate] - Start date for the summary (ISO format)
|
||||
* @param {string} [options.endDate] - End date for the summary (ISO format)
|
||||
* @param {string} [options.granularity='monthly'] - Time granularity ('daily', 'monthly', 'yearly')
|
||||
* @returns {Promise<Object>} Summary of expenses per model and time period
|
||||
*/
|
||||
async function getModelExpenses({ startDate, endDate, granularity = 'monthly' } = {}) {
|
||||
const where = {};
|
||||
|
||||
// Add date range if provided
|
||||
if (startDate || endDate) {
|
||||
where.created_at = {};
|
||||
if (startDate) where.created_at[Op.gte] = new Date(startDate);
|
||||
if (endDate) where.created_at[Op.lte] = new Date(endDate);
|
||||
}
|
||||
|
||||
// Define time period format based on granularity
|
||||
const timeFormat = {
|
||||
daily: '%Y-%m-%d',
|
||||
monthly: '%Y-%m',
|
||||
yearly: '%Y'
|
||||
}[granularity] || '%Y-%m'; // Default to monthly if invalid granularity
|
||||
|
||||
// Get all prompts within the date range
|
||||
const prompts = await Prompt.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'model',
|
||||
[sequelize.fn('date_trunc', granularity, sequelize.col('created_at')), 'time_period'],
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'total_requests'],
|
||||
[sequelize.fn('SUM', sequelize.col('prompt_tokens')), 'total_prompt_tokens'],
|
||||
[sequelize.fn('SUM', sequelize.col('response_tokens')), 'total_response_tokens'],
|
||||
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'total_tokens'],
|
||||
[sequelize.fn('SUM', sequelize.col('your_cost_usd')), 'total_cost'],
|
||||
[sequelize.fn('AVG', sequelize.col('your_cost_usd')), 'avg_cost_per_request'],
|
||||
[sequelize.fn('SUM', sequelize.col('cost_gpt4_usd')), 'total_gpt4_cost'],
|
||||
[sequelize.fn('SUM', sequelize.col('cost_claude_usd')), 'total_claude_cost'],
|
||||
[sequelize.fn('SUM', sequelize.col('cost_gemini_usd')), 'total_gemini_cost'],
|
||||
[sequelize.fn('SUM', sequelize.col('savings_vs_gpt4')), 'total_savings_vs_gpt4'],
|
||||
[sequelize.fn('SUM', sequelize.col('savings_vs_claude')), 'total_savings_vs_claude'],
|
||||
[sequelize.fn('SUM', sequelize.col('savings_vs_gemini')), 'total_savings_vs_gemini']
|
||||
],
|
||||
group: ['model', 'time_period'],
|
||||
order: [
|
||||
['time_period', 'DESC'],
|
||||
[sequelize.fn('SUM', sequelize.col('your_cost_usd')), 'DESC']
|
||||
]
|
||||
});
|
||||
|
||||
// Group results by time period
|
||||
const groupedByTime = prompts.reduce((acc, model) => {
|
||||
const timePeriod = model.getDataValue('time_period');
|
||||
const modelData = {
|
||||
model: model.getDataValue('model'),
|
||||
total_requests: parseInt(model.getDataValue('total_requests')),
|
||||
total_prompt_tokens: parseInt(model.getDataValue('total_prompt_tokens') || 0),
|
||||
total_response_tokens: parseInt(model.getDataValue('total_response_tokens') || 0),
|
||||
total_tokens: parseInt(model.getDataValue('total_tokens') || 0),
|
||||
total_cost: parseFloat(model.getDataValue('total_cost') || 0).toFixed(6),
|
||||
avg_cost_per_request: parseFloat(model.getDataValue('avg_cost_per_request') || 0).toFixed(6),
|
||||
total_gpt4_cost: parseFloat(model.getDataValue('total_gpt4_cost') || 0).toFixed(6),
|
||||
total_claude_cost: parseFloat(model.getDataValue('total_claude_cost') || 0).toFixed(6),
|
||||
total_gemini_cost: parseFloat(model.getDataValue('total_gemini_cost') || 0).toFixed(6),
|
||||
total_savings_vs_gpt4: parseFloat(model.getDataValue('total_savings_vs_gpt4') || 0).toFixed(6),
|
||||
total_savings_vs_claude: parseFloat(model.getDataValue('total_savings_vs_claude') || 0).toFixed(6),
|
||||
total_savings_vs_gemini: parseFloat(model.getDataValue('total_savings_vs_gemini') || 0).toFixed(6)
|
||||
};
|
||||
|
||||
if (!acc[timePeriod]) {
|
||||
acc[timePeriod] = {
|
||||
models: [],
|
||||
totals: {
|
||||
total_requests: 0,
|
||||
total_prompt_tokens: 0,
|
||||
total_response_tokens: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_gpt4_cost: 0,
|
||||
total_claude_cost: 0,
|
||||
total_gemini_cost: 0,
|
||||
total_savings_vs_gpt4: 0,
|
||||
total_savings_vs_claude: 0,
|
||||
total_savings_vs_gemini: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
acc[timePeriod].models.push(modelData);
|
||||
|
||||
// Update period totals
|
||||
const totals = acc[timePeriod].totals;
|
||||
totals.total_requests += modelData.total_requests;
|
||||
totals.total_prompt_tokens += modelData.total_prompt_tokens;
|
||||
totals.total_response_tokens += modelData.total_response_tokens;
|
||||
totals.total_tokens += modelData.total_tokens;
|
||||
totals.total_cost += parseFloat(modelData.total_cost);
|
||||
totals.total_gpt4_cost += parseFloat(modelData.total_gpt4_cost);
|
||||
totals.total_claude_cost += parseFloat(modelData.total_claude_cost);
|
||||
totals.total_gemini_cost += parseFloat(modelData.total_gemini_cost);
|
||||
totals.total_savings_vs_gpt4 += parseFloat(modelData.total_savings_vs_gpt4);
|
||||
totals.total_savings_vs_claude += parseFloat(modelData.total_savings_vs_claude);
|
||||
totals.total_savings_vs_gemini += parseFloat(modelData.total_savings_vs_gemini);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Format the response
|
||||
const periods = Object.entries(groupedByTime).map(([timePeriod, data]) => {
|
||||
const totals = data.totals;
|
||||
totals.avg_cost_per_request = totals.total_requests > 0
|
||||
? (totals.total_cost / totals.total_requests).toFixed(6)
|
||||
: '0.000000';
|
||||
|
||||
// Format all totals to 6 decimal places
|
||||
Object.keys(totals).forEach(key => {
|
||||
if (typeof totals[key] === 'number') {
|
||||
totals[key] = totals[key].toFixed(6);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
time_period: timePeriod,
|
||||
models: data.models,
|
||||
totals
|
||||
};
|
||||
});
|
||||
|
||||
// Sort periods by time (newest first)
|
||||
periods.sort((a, b) => new Date(b.time_period) - new Date(a.time_period));
|
||||
|
||||
return {
|
||||
granularity,
|
||||
periods,
|
||||
date_range: {
|
||||
start: startDate || null,
|
||||
end: endDate || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getModelExpenses
|
||||
};
|
70
src/api/network/stats.js
Normal file
70
src/api/network/stats.js
Normal file
@ -0,0 +1,70 @@
|
||||
const { getModelExpenses } = require('../controller/stats');
|
||||
|
||||
/**
|
||||
* Get expense summaries per model
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
async function getExpenses(req, res) {
|
||||
try {
|
||||
const { start_date, end_date, granularity = 'monthly' } = req.query;
|
||||
|
||||
// Validate granularity
|
||||
if (!['daily', 'monthly', 'yearly'].includes(granularity)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid granularity. Must be one of: daily, monthly, yearly'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date formats if provided
|
||||
if (start_date && !isValidDate(start_date)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid start_date format. Use ISO 8601 format (YYYY-MM-DD)'
|
||||
});
|
||||
}
|
||||
|
||||
if (end_date && !isValidDate(end_date)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid end_date format. Use ISO 8601 format (YYYY-MM-DD)'
|
||||
});
|
||||
}
|
||||
|
||||
const summary = await getModelExpenses({
|
||||
startDate: start_date,
|
||||
endDate: end_date,
|
||||
granularity
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching expense summary:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch expense summary',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date string format (YYYY-MM-DD)
|
||||
* @param {string} dateString - The date string to validate
|
||||
* @returns {boolean} Whether the date string is valid
|
||||
*/
|
||||
function isValidDate(dateString) {
|
||||
const regex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!regex.test(dateString)) return false;
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date instanceof Date && !isNaN(date);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getExpenses
|
||||
};
|
8
src/app.js
Normal file
8
src/app.js
Normal file
@ -0,0 +1,8 @@
|
||||
const generateRoutes = require('./routes/generate');
|
||||
const logsRoutes = require('./routes/logs');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
|
||||
// API routes
|
||||
app.use('/api/generate', generateRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
16
src/routes/stats.js
Normal file
16
src/routes/stats.js
Normal file
@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { validateApiKey } = require('../middleware/auth');
|
||||
const { getExpenses } = require('../api/network/stats');
|
||||
|
||||
// Apply API key validation to all routes
|
||||
router.use(validateApiKey);
|
||||
|
||||
// Get expense summaries per model
|
||||
// Optional query parameters:
|
||||
// - start_date: Start date in YYYY-MM-DD format
|
||||
// - end_date: End date in YYYY-MM-DD format
|
||||
// - granularity: Time granularity ('daily', 'monthly', 'yearly'). Defaults to 'monthly'
|
||||
router.get('/expenses', getExpenses);
|
||||
|
||||
module.exports = router;
|
Reference in New Issue
Block a user