mcp-rubber-duck 1.5.2 ā 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/pricing-updater.md +111 -0
- package/.claude/commands/update-pricing.md +22 -0
- package/CHANGELOG.md +14 -0
- package/README.md +23 -0
- package/dist/config/types.d.ts +72 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +8 -0
- package/dist/config/types.js.map +1 -1
- package/dist/data/default-pricing.d.ts +18 -0
- package/dist/data/default-pricing.d.ts.map +1 -0
- package/dist/data/default-pricing.js +307 -0
- package/dist/data/default-pricing.js.map +1 -0
- package/dist/providers/enhanced-manager.d.ts +2 -1
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +20 -2
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +3 -1
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +12 -1
- package/dist/providers/manager.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +35 -4
- package/dist/server.js.map +1 -1
- package/dist/services/pricing.d.ts +56 -0
- package/dist/services/pricing.d.ts.map +1 -0
- package/dist/services/pricing.js +124 -0
- package/dist/services/pricing.js.map +1 -0
- package/dist/services/usage.d.ts +48 -0
- package/dist/services/usage.d.ts.map +1 -0
- package/dist/services/usage.js +243 -0
- package/dist/services/usage.js.map +1 -0
- package/dist/tools/get-usage-stats.d.ts +8 -0
- package/dist/tools/get-usage-stats.d.ts.map +1 -0
- package/dist/tools/get-usage-stats.js +92 -0
- package/dist/tools/get-usage-stats.js.map +1 -0
- package/package.json +1 -1
- package/src/config/types.ts +51 -0
- package/src/data/default-pricing.ts +368 -0
- package/src/providers/enhanced-manager.ts +41 -4
- package/src/providers/manager.ts +22 -1
- package/src/server.ts +42 -4
- package/src/services/pricing.ts +155 -0
- package/src/services/usage.ts +293 -0
- package/src/tools/get-usage-stats.ts +109 -0
- package/tests/pricing.test.ts +335 -0
- package/tests/tools/get-usage-stats.test.ts +236 -0
- package/tests/usage.test.ts +661 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { UsageService } from '../services/usage.js';
|
|
2
|
+
import { UsageTimePeriod } from '../config/types.js';
|
|
3
|
+
import { duckArt } from '../utils/ascii-art.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
const VALID_PERIODS: UsageTimePeriod[] = ['today', '7d', '30d', 'all'];
|
|
7
|
+
|
|
8
|
+
export function getUsageStatsTool(
|
|
9
|
+
usageService: UsageService,
|
|
10
|
+
args: Record<string, unknown>
|
|
11
|
+
) {
|
|
12
|
+
const { period = 'today' } = args as { period?: string };
|
|
13
|
+
|
|
14
|
+
// Validate period
|
|
15
|
+
if (!VALID_PERIODS.includes(period as UsageTimePeriod)) {
|
|
16
|
+
throw new Error(`Invalid period "${period}". Valid options: ${VALID_PERIODS.join(', ')}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const stats = usageService.getStats(period as UsageTimePeriod);
|
|
20
|
+
|
|
21
|
+
// Format output
|
|
22
|
+
let output = `${duckArt.panel}\n\n`;
|
|
23
|
+
output += `š Usage Statistics: ${formatPeriodLabel(period as UsageTimePeriod)}\n`;
|
|
24
|
+
output += `āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n`;
|
|
25
|
+
output += `Period: ${stats.startDate} to ${stats.endDate}\n\n`;
|
|
26
|
+
|
|
27
|
+
// Totals section
|
|
28
|
+
output += `š TOTALS\n`;
|
|
29
|
+
output += `āāāāāāāāāāāāāāāāāāāāā\n`;
|
|
30
|
+
output += `Requests: ${stats.totals.requests.toLocaleString()}\n`;
|
|
31
|
+
output += `Prompt Tokens: ${stats.totals.promptTokens.toLocaleString()}\n`;
|
|
32
|
+
output += `Completion Tokens: ${stats.totals.completionTokens.toLocaleString()}\n`;
|
|
33
|
+
output += `Total Tokens: ${(stats.totals.promptTokens + stats.totals.completionTokens).toLocaleString()}\n`;
|
|
34
|
+
output += `Cache Hits: ${stats.totals.cacheHits.toLocaleString()}\n`;
|
|
35
|
+
output += `Errors: ${stats.totals.errors.toLocaleString()}\n`;
|
|
36
|
+
|
|
37
|
+
if (stats.totals.estimatedCostUSD !== undefined) {
|
|
38
|
+
output += `š° Estimated Cost: $${formatCost(stats.totals.estimatedCostUSD)} USD\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Per-provider breakdown
|
|
42
|
+
const providers = Object.keys(stats.usage);
|
|
43
|
+
if (providers.length > 0) {
|
|
44
|
+
output += `\nš¦ BY PROVIDER\n`;
|
|
45
|
+
output += `āāāāāāāāāāāāāāāāāāāāā\n`;
|
|
46
|
+
|
|
47
|
+
for (const provider of providers) {
|
|
48
|
+
const models = stats.usage[provider];
|
|
49
|
+
output += `\n**${provider}**\n`;
|
|
50
|
+
|
|
51
|
+
for (const [model, modelStats] of Object.entries(models)) {
|
|
52
|
+
output += ` ${model}:\n`;
|
|
53
|
+
output += ` Requests: ${modelStats.requests.toLocaleString()}\n`;
|
|
54
|
+
output += ` Tokens: ${modelStats.promptTokens.toLocaleString()} in / ${modelStats.completionTokens.toLocaleString()} out\n`;
|
|
55
|
+
if (modelStats.cacheHits > 0) {
|
|
56
|
+
output += ` Cache Hits: ${modelStats.cacheHits.toLocaleString()}\n`;
|
|
57
|
+
}
|
|
58
|
+
if (modelStats.errors > 0) {
|
|
59
|
+
output += ` Errors: ${modelStats.errors.toLocaleString()}\n`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (stats.costByProvider && stats.costByProvider[provider] !== undefined) {
|
|
64
|
+
output += ` š° Provider Cost: $${formatCost(stats.costByProvider[provider])}\n`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
output += `\nNo usage data for this period.\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Footer note about cost
|
|
72
|
+
if (stats.totals.estimatedCostUSD === undefined && stats.totals.requests > 0) {
|
|
73
|
+
output += `\nš” Cost estimates not available. Configure pricing in config.json or update to latest version.\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
logger.info(`Retrieved usage stats for period: ${period}`);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: output,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatPeriodLabel(period: UsageTimePeriod): string {
|
|
89
|
+
switch (period) {
|
|
90
|
+
case 'today':
|
|
91
|
+
return 'Today';
|
|
92
|
+
case '7d':
|
|
93
|
+
return 'Last 7 Days';
|
|
94
|
+
case '30d':
|
|
95
|
+
return 'Last 30 Days';
|
|
96
|
+
case 'all':
|
|
97
|
+
return 'All Time';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatCost(cost: number): string {
|
|
102
|
+
if (cost < 0.01) {
|
|
103
|
+
return cost.toFixed(6);
|
|
104
|
+
} else if (cost < 1) {
|
|
105
|
+
return cost.toFixed(4);
|
|
106
|
+
} else {
|
|
107
|
+
return cost.toFixed(2);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { PricingService } from '../src/services/pricing.js';
|
|
3
|
+
import { PricingConfig } from '../src/config/types.js';
|
|
4
|
+
import { DEFAULT_PRICING } from '../src/data/default-pricing.js';
|
|
5
|
+
|
|
6
|
+
describe('PricingService', () => {
|
|
7
|
+
describe('default pricing', () => {
|
|
8
|
+
let service: PricingService;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
service = new PricingService();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should load default pricing when no config override', () => {
|
|
15
|
+
// Check a known default price
|
|
16
|
+
const pricing = service.getPricing('openai', 'gpt-4o');
|
|
17
|
+
expect(pricing).toBeDefined();
|
|
18
|
+
expect(pricing?.inputPricePerMillion).toBe(2.5);
|
|
19
|
+
expect(pricing?.outputPricePerMillion).toBe(10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should have pricing for common providers', () => {
|
|
23
|
+
expect(service.getPricing('openai', 'gpt-4o')).toBeDefined();
|
|
24
|
+
expect(service.getPricing('anthropic', 'claude-3-5-sonnet-20241022')).toBeDefined();
|
|
25
|
+
expect(service.getPricing('google', 'gemini-1.5-pro')).toBeDefined();
|
|
26
|
+
expect(service.getPricing('groq', 'llama-3.3-70b-versatile')).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return undefined for unknown provider', () => {
|
|
30
|
+
expect(service.getPricing('unknown-provider', 'some-model')).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return undefined for unknown model', () => {
|
|
34
|
+
expect(service.getPricing('openai', 'unknown-model')).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should list all providers', () => {
|
|
38
|
+
const providers = service.getProviders();
|
|
39
|
+
expect(providers).toContain('openai');
|
|
40
|
+
expect(providers).toContain('anthropic');
|
|
41
|
+
expect(providers).toContain('google');
|
|
42
|
+
expect(providers).toContain('groq');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should list models for a provider', () => {
|
|
46
|
+
const models = service.getModelsForProvider('openai');
|
|
47
|
+
expect(models).toContain('gpt-4o');
|
|
48
|
+
expect(models).toContain('gpt-4o-mini');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return empty array for unknown provider models', () => {
|
|
52
|
+
const models = service.getModelsForProvider('unknown-provider');
|
|
53
|
+
expect(models).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('config overrides', () => {
|
|
58
|
+
it('should override default pricing with config values', () => {
|
|
59
|
+
const configPricing: PricingConfig = {
|
|
60
|
+
openai: {
|
|
61
|
+
'gpt-4o': { inputPricePerMillion: 100, outputPricePerMillion: 200 },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const service = new PricingService(configPricing);
|
|
66
|
+
const pricing = service.getPricing('openai', 'gpt-4o');
|
|
67
|
+
|
|
68
|
+
expect(pricing?.inputPricePerMillion).toBe(100);
|
|
69
|
+
expect(pricing?.outputPricePerMillion).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should add new providers from config', () => {
|
|
73
|
+
const configPricing: PricingConfig = {
|
|
74
|
+
'my-custom-provider': {
|
|
75
|
+
'custom-model': { inputPricePerMillion: 1, outputPricePerMillion: 2 },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const service = new PricingService(configPricing);
|
|
80
|
+
const pricing = service.getPricing('my-custom-provider', 'custom-model');
|
|
81
|
+
|
|
82
|
+
expect(pricing?.inputPricePerMillion).toBe(1);
|
|
83
|
+
expect(pricing?.outputPricePerMillion).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should add new models to existing providers', () => {
|
|
87
|
+
const configPricing: PricingConfig = {
|
|
88
|
+
openai: {
|
|
89
|
+
'new-custom-model': { inputPricePerMillion: 5, outputPricePerMillion: 10 },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const service = new PricingService(configPricing);
|
|
94
|
+
|
|
95
|
+
// New model should exist
|
|
96
|
+
const newPricing = service.getPricing('openai', 'new-custom-model');
|
|
97
|
+
expect(newPricing?.inputPricePerMillion).toBe(5);
|
|
98
|
+
|
|
99
|
+
// Existing default models should still exist
|
|
100
|
+
const existingPricing = service.getPricing('openai', 'gpt-4o-mini');
|
|
101
|
+
expect(existingPricing).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should preserve default providers when config adds new ones', () => {
|
|
105
|
+
const configPricing: PricingConfig = {
|
|
106
|
+
'new-provider': {
|
|
107
|
+
'new-model': { inputPricePerMillion: 1, outputPricePerMillion: 2 },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const service = new PricingService(configPricing);
|
|
112
|
+
|
|
113
|
+
// New provider should exist
|
|
114
|
+
expect(service.getPricing('new-provider', 'new-model')).toBeDefined();
|
|
115
|
+
|
|
116
|
+
// Default providers should still exist
|
|
117
|
+
expect(service.getPricing('openai', 'gpt-4o')).toBeDefined();
|
|
118
|
+
expect(service.getPricing('anthropic', 'claude-3-5-sonnet-20241022')).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should handle empty config override object', () => {
|
|
122
|
+
const service = new PricingService({});
|
|
123
|
+
|
|
124
|
+
// Default providers should still exist
|
|
125
|
+
expect(service.getPricing('openai', 'gpt-4o')).toBeDefined();
|
|
126
|
+
expect(service.getProviders().length).toBeGreaterThan(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should be case-sensitive for provider and model names', () => {
|
|
130
|
+
const service = new PricingService();
|
|
131
|
+
|
|
132
|
+
// Exact case should work
|
|
133
|
+
expect(service.getPricing('openai', 'gpt-4o')).toBeDefined();
|
|
134
|
+
|
|
135
|
+
// Different case should NOT work
|
|
136
|
+
expect(service.getPricing('OpenAI', 'gpt-4o')).toBeUndefined();
|
|
137
|
+
expect(service.getPricing('openai', 'GPT-4o')).toBeUndefined();
|
|
138
|
+
expect(service.getPricing('OPENAI', 'GPT-4O')).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('cost calculation', () => {
|
|
143
|
+
let service: PricingService;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
service = new PricingService({
|
|
147
|
+
test: {
|
|
148
|
+
'test-model': { inputPricePerMillion: 5, outputPricePerMillion: 15 },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should calculate cost correctly', () => {
|
|
154
|
+
// 500 prompt tokens at $5/M = $0.0025
|
|
155
|
+
// 200 completion tokens at $15/M = $0.003
|
|
156
|
+
// Total = $0.0055
|
|
157
|
+
const cost = service.calculateCost('test', 'test-model', 500, 200);
|
|
158
|
+
|
|
159
|
+
expect(cost).not.toBeNull();
|
|
160
|
+
expect(cost?.inputCost).toBeCloseTo(0.0025, 6);
|
|
161
|
+
expect(cost?.outputCost).toBeCloseTo(0.003, 6);
|
|
162
|
+
expect(cost?.totalCost).toBeCloseTo(0.0055, 6);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should return null for unknown provider', () => {
|
|
166
|
+
const cost = service.calculateCost('unknown', 'model', 1000, 1000);
|
|
167
|
+
expect(cost).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should return null for unknown model', () => {
|
|
171
|
+
const cost = service.calculateCost('test', 'unknown-model', 1000, 1000);
|
|
172
|
+
expect(cost).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should return zero cost for zero tokens', () => {
|
|
176
|
+
const cost = service.calculateCost('test', 'test-model', 0, 0);
|
|
177
|
+
|
|
178
|
+
expect(cost).not.toBeNull();
|
|
179
|
+
expect(cost?.inputCost).toBe(0);
|
|
180
|
+
expect(cost?.outputCost).toBe(0);
|
|
181
|
+
expect(cost?.totalCost).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle large token counts', () => {
|
|
185
|
+
// 1 million tokens at $5/M = $5
|
|
186
|
+
const cost = service.calculateCost('test', 'test-model', 1_000_000, 1_000_000);
|
|
187
|
+
|
|
188
|
+
expect(cost).not.toBeNull();
|
|
189
|
+
expect(cost?.inputCost).toBe(5);
|
|
190
|
+
expect(cost?.outputCost).toBe(15);
|
|
191
|
+
expect(cost?.totalCost).toBe(20);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should handle fractional prices correctly', () => {
|
|
195
|
+
const serviceWithFractional = new PricingService({
|
|
196
|
+
test: {
|
|
197
|
+
'cheap-model': { inputPricePerMillion: 0.15, outputPricePerMillion: 0.6 },
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 1000 tokens at $0.15/M = $0.00015
|
|
202
|
+
const cost = serviceWithFractional.calculateCost('test', 'cheap-model', 1000, 1000);
|
|
203
|
+
|
|
204
|
+
expect(cost).not.toBeNull();
|
|
205
|
+
expect(cost?.inputCost).toBeCloseTo(0.00015, 8);
|
|
206
|
+
expect(cost?.outputCost).toBeCloseTo(0.0006, 8);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should calculate cost for free models as zero', () => {
|
|
210
|
+
const serviceWithFree = new PricingService({
|
|
211
|
+
test: {
|
|
212
|
+
'free-model': { inputPricePerMillion: 0, outputPricePerMillion: 0 },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const cost = serviceWithFree.calculateCost('test', 'free-model', 10000, 5000);
|
|
217
|
+
|
|
218
|
+
expect(cost).not.toBeNull();
|
|
219
|
+
expect(cost?.totalCost).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('hasPricingFor', () => {
|
|
224
|
+
let service: PricingService;
|
|
225
|
+
|
|
226
|
+
beforeEach(() => {
|
|
227
|
+
service = new PricingService();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should return true for known provider/model', () => {
|
|
231
|
+
expect(service.hasPricingFor('openai', 'gpt-4o')).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should return false for unknown provider', () => {
|
|
235
|
+
expect(service.hasPricingFor('unknown', 'gpt-4o')).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should return false for unknown model', () => {
|
|
239
|
+
expect(service.hasPricingFor('openai', 'unknown-model')).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('provider aliases', () => {
|
|
244
|
+
let service: PricingService;
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
service = new PricingService();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should resolve "gemini" to "google" pricing', () => {
|
|
251
|
+
const pricing = service.getPricing('gemini', 'gemini-2.5-flash');
|
|
252
|
+
expect(pricing).toBeDefined();
|
|
253
|
+
expect(pricing?.inputPricePerMillion).toBe(0.3);
|
|
254
|
+
expect(pricing?.outputPricePerMillion).toBe(2.5);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should still work with canonical "google" provider name', () => {
|
|
258
|
+
const pricing = service.getPricing('google', 'gemini-2.5-flash');
|
|
259
|
+
expect(pricing).toBeDefined();
|
|
260
|
+
expect(pricing?.inputPricePerMillion).toBe(0.3);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should calculate cost with aliased provider', () => {
|
|
264
|
+
const cost = service.calculateCost('gemini', 'gemini-2.5-flash', 1_000_000, 1_000_000);
|
|
265
|
+
expect(cost).not.toBeNull();
|
|
266
|
+
expect(cost?.inputCost).toBe(0.3);
|
|
267
|
+
expect(cost?.outputCost).toBe(2.5);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should return true for hasPricingFor with aliased provider', () => {
|
|
271
|
+
expect(service.hasPricingFor('gemini', 'gemini-2.5-flash')).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should list models for aliased provider', () => {
|
|
275
|
+
const models = service.getModelsForProvider('gemini');
|
|
276
|
+
expect(models).toContain('gemini-2.5-flash');
|
|
277
|
+
expect(models).toContain('gemini-1.5-pro');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should prefer direct provider match over alias', () => {
|
|
281
|
+
// If user adds "gemini" in their config, it should take precedence
|
|
282
|
+
const configPricing: PricingConfig = {
|
|
283
|
+
gemini: {
|
|
284
|
+
'custom-gemini-model': { inputPricePerMillion: 99, outputPricePerMillion: 199 },
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
const serviceWithOverride = new PricingService(configPricing);
|
|
288
|
+
|
|
289
|
+
// Custom model should work
|
|
290
|
+
const customPricing = serviceWithOverride.getPricing('gemini', 'custom-gemini-model');
|
|
291
|
+
expect(customPricing?.inputPricePerMillion).toBe(99);
|
|
292
|
+
|
|
293
|
+
// But google models won't be accessible via "gemini" anymore since direct match wins
|
|
294
|
+
const googlePricing = serviceWithOverride.getPricing('gemini', 'gemini-2.5-flash');
|
|
295
|
+
expect(googlePricing).toBeUndefined();
|
|
296
|
+
|
|
297
|
+
// Google models still accessible via "google"
|
|
298
|
+
const directPricing = serviceWithOverride.getPricing('google', 'gemini-2.5-flash');
|
|
299
|
+
expect(directPricing).toBeDefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('getAllPricing', () => {
|
|
304
|
+
it('should return all pricing data', () => {
|
|
305
|
+
const service = new PricingService();
|
|
306
|
+
const allPricing = service.getAllPricing();
|
|
307
|
+
|
|
308
|
+
expect(allPricing).toHaveProperty('openai');
|
|
309
|
+
expect(allPricing).toHaveProperty('anthropic');
|
|
310
|
+
expect(allPricing.openai).toHaveProperty('gpt-4o');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should return a copy, not the original', () => {
|
|
314
|
+
const service = new PricingService();
|
|
315
|
+
const pricing1 = service.getAllPricing();
|
|
316
|
+
const pricing2 = service.getAllPricing();
|
|
317
|
+
|
|
318
|
+
expect(pricing1).not.toBe(pricing2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return a deep copy (mutations do not affect original)', () => {
|
|
322
|
+
const service = new PricingService();
|
|
323
|
+
const pricing = service.getAllPricing();
|
|
324
|
+
|
|
325
|
+
// Store original values
|
|
326
|
+
const originalInput = service.getPricing('openai', 'gpt-4o')?.inputPricePerMillion;
|
|
327
|
+
|
|
328
|
+
// Mutate the copy
|
|
329
|
+
pricing.openai['gpt-4o'].inputPricePerMillion = 999;
|
|
330
|
+
|
|
331
|
+
// Original should be unchanged
|
|
332
|
+
expect(service.getPricing('openai', 'gpt-4o')?.inputPricePerMillion).toBe(originalInput);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import { getUsageStatsTool } from '../../src/tools/get-usage-stats.js';
|
|
3
|
+
import { UsageService } from '../../src/services/usage.js';
|
|
4
|
+
import { PricingService } from '../../src/services/pricing.js';
|
|
5
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
|
|
9
|
+
// Mock logger to avoid console noise during tests
|
|
10
|
+
jest.mock('../../src/utils/logger');
|
|
11
|
+
|
|
12
|
+
describe('getUsageStatsTool', () => {
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
let pricingService: PricingService;
|
|
15
|
+
let usageService: UsageService;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tempDir = mkdtempSync(join(tmpdir(), 'usage-tool-test-'));
|
|
19
|
+
|
|
20
|
+
pricingService = new PricingService({
|
|
21
|
+
testprovider: {
|
|
22
|
+
'test-model': { inputPricePerMillion: 5, outputPricePerMillion: 15 },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
usageService = new UsageService(pricingService, {
|
|
27
|
+
dataDir: tempDir,
|
|
28
|
+
debounceMs: 0,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
usageService.shutdown();
|
|
34
|
+
if (existsSync(tempDir)) {
|
|
35
|
+
rmSync(tempDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('input validation', () => {
|
|
40
|
+
it('should throw error for invalid period', () => {
|
|
41
|
+
expect(() => {
|
|
42
|
+
getUsageStatsTool(usageService, { period: 'invalid' });
|
|
43
|
+
}).toThrow('Invalid period "invalid"');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should accept valid periods', () => {
|
|
47
|
+
expect(() => getUsageStatsTool(usageService, { period: 'today' })).not.toThrow();
|
|
48
|
+
expect(() => getUsageStatsTool(usageService, { period: '7d' })).not.toThrow();
|
|
49
|
+
expect(() => getUsageStatsTool(usageService, { period: '30d' })).not.toThrow();
|
|
50
|
+
expect(() => getUsageStatsTool(usageService, { period: 'all' })).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should default to today when period not specified', () => {
|
|
54
|
+
const result = getUsageStatsTool(usageService, {});
|
|
55
|
+
|
|
56
|
+
expect(result.content[0].text).toContain('Today');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('output format', () => {
|
|
61
|
+
it('should return MCP-compliant response', () => {
|
|
62
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
63
|
+
|
|
64
|
+
expect(result.content).toBeDefined();
|
|
65
|
+
expect(result.content).toHaveLength(1);
|
|
66
|
+
expect(result.content[0].type).toBe('text');
|
|
67
|
+
expect(typeof result.content[0].text).toBe('string');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should include period label in output', () => {
|
|
71
|
+
const result = getUsageStatsTool(usageService, { period: '7d' });
|
|
72
|
+
expect(result.content[0].text).toContain('Last 7 Days');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should include date range', () => {
|
|
76
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
77
|
+
// Should contain dates in YYYY-MM-DD format
|
|
78
|
+
expect(result.content[0].text).toMatch(/\d{4}-\d{2}-\d{2}/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should include totals section', () => {
|
|
82
|
+
usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
|
|
83
|
+
|
|
84
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
85
|
+
const text = result.content[0].text;
|
|
86
|
+
|
|
87
|
+
expect(text).toContain('TOTALS');
|
|
88
|
+
expect(text).toContain('Requests:');
|
|
89
|
+
expect(text).toContain('Prompt Tokens:');
|
|
90
|
+
expect(text).toContain('Completion Tokens:');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should include per-provider breakdown', () => {
|
|
94
|
+
usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
|
|
95
|
+
usageService.recordUsage('anthropic', 'claude-3', 200, 100, false, false);
|
|
96
|
+
|
|
97
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
98
|
+
const text = result.content[0].text;
|
|
99
|
+
|
|
100
|
+
expect(text).toContain('BY PROVIDER');
|
|
101
|
+
expect(text).toContain('openai');
|
|
102
|
+
expect(text).toContain('anthropic');
|
|
103
|
+
expect(text).toContain('gpt-4o');
|
|
104
|
+
expect(text).toContain('claude-3');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should show cost when pricing available', () => {
|
|
108
|
+
usageService.recordUsage('testprovider', 'test-model', 1000, 500, false, false);
|
|
109
|
+
|
|
110
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
111
|
+
const text = result.content[0].text;
|
|
112
|
+
|
|
113
|
+
expect(text).toContain('Estimated Cost:');
|
|
114
|
+
expect(text).toContain('$');
|
|
115
|
+
expect(text).toContain('USD');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should show hint when cost unavailable', () => {
|
|
119
|
+
usageService.recordUsage('unknown-provider', 'unknown-model', 1000, 500, false, false);
|
|
120
|
+
|
|
121
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
122
|
+
const text = result.content[0].text;
|
|
123
|
+
|
|
124
|
+
expect(text).toContain('Cost estimates not available');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle empty usage gracefully', () => {
|
|
128
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
129
|
+
const text = result.content[0].text;
|
|
130
|
+
|
|
131
|
+
expect(text).toContain('No usage data');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should show cache hits when present', () => {
|
|
135
|
+
usageService.recordUsage('openai', 'gpt-4o', 100, 50, true, false);
|
|
136
|
+
|
|
137
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
138
|
+
const text = result.content[0].text;
|
|
139
|
+
|
|
140
|
+
expect(text).toContain('Cache Hits:');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should show errors when present', () => {
|
|
144
|
+
usageService.recordUsage('openai', 'gpt-4o', 0, 0, false, true);
|
|
145
|
+
|
|
146
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
147
|
+
const text = result.content[0].text;
|
|
148
|
+
|
|
149
|
+
expect(text).toContain('Errors:');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('period filtering', () => {
|
|
154
|
+
it('should filter data by period', () => {
|
|
155
|
+
// Record some usage for today
|
|
156
|
+
usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
|
|
157
|
+
|
|
158
|
+
// Today should have data
|
|
159
|
+
const todayResult = getUsageStatsTool(usageService, { period: 'today' });
|
|
160
|
+
expect(todayResult.content[0].text).toContain('Requests: 1');
|
|
161
|
+
|
|
162
|
+
// All should also have data
|
|
163
|
+
const allResult = getUsageStatsTool(usageService, { period: 'all' });
|
|
164
|
+
expect(allResult.content[0].text).toContain('Requests: 1');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('formatting', () => {
|
|
169
|
+
it('should format large numbers with commas', () => {
|
|
170
|
+
usageService.recordUsage('openai', 'gpt-4o', 1000000, 500000, false, false);
|
|
171
|
+
|
|
172
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
173
|
+
const text = result.content[0].text;
|
|
174
|
+
|
|
175
|
+
// Should have formatted numbers
|
|
176
|
+
expect(text).toContain('1,000,000');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should format cost with appropriate precision', () => {
|
|
180
|
+
// Small cost
|
|
181
|
+
usageService.recordUsage('testprovider', 'test-model', 100, 50, false, false);
|
|
182
|
+
|
|
183
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
184
|
+
const text = result.content[0].text;
|
|
185
|
+
|
|
186
|
+
// Should show cost with decimal places
|
|
187
|
+
expect(text).toMatch(/\$\d+\.\d+/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should format very small costs with 6 decimal places', () => {
|
|
191
|
+
// Very small cost (10 tokens at $5/M = $0.00005)
|
|
192
|
+
usageService.recordUsage('testprovider', 'test-model', 10, 0, false, false);
|
|
193
|
+
|
|
194
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
195
|
+
const text = result.content[0].text;
|
|
196
|
+
|
|
197
|
+
// Should show 6 decimal places for very small amounts
|
|
198
|
+
expect(text).toMatch(/\$0\.0000\d+/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should format large costs with 2 decimal places', () => {
|
|
202
|
+
// Large cost (10M tokens at $5/M = $50)
|
|
203
|
+
usageService.recordUsage('testprovider', 'test-model', 10000000, 0, false, false);
|
|
204
|
+
|
|
205
|
+
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
206
|
+
const text = result.content[0].text;
|
|
207
|
+
|
|
208
|
+
// Should show 2 decimal places for larger amounts
|
|
209
|
+
expect(text).toMatch(/\$\d+\.\d{2}\s*USD/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should show $0 cost for free models', () => {
|
|
213
|
+
// Create service with free pricing
|
|
214
|
+
const freePricingService = new PricingService({
|
|
215
|
+
freeprovider: {
|
|
216
|
+
'free-model': { inputPricePerMillion: 0, outputPricePerMillion: 0 },
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const freeUsageService = new UsageService(freePricingService, {
|
|
220
|
+
dataDir: tempDir,
|
|
221
|
+
debounceMs: 0,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
freeUsageService.recordUsage('freeprovider', 'free-model', 1000000, 500000, false, false);
|
|
225
|
+
|
|
226
|
+
const result = getUsageStatsTool(freeUsageService, { period: 'today' });
|
|
227
|
+
const text = result.content[0].text;
|
|
228
|
+
|
|
229
|
+
// Should show $0 cost
|
|
230
|
+
expect(text).toContain('$0');
|
|
231
|
+
expect(text).toContain('Estimated Cost:');
|
|
232
|
+
|
|
233
|
+
freeUsageService.shutdown();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|