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