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.
Files changed (48) hide show
  1. package/.claude/agents/pricing-updater.md +111 -0
  2. package/.claude/commands/update-pricing.md +22 -0
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +23 -0
  5. package/dist/config/types.d.ts +72 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/config/types.js +8 -0
  8. package/dist/config/types.js.map +1 -1
  9. package/dist/data/default-pricing.d.ts +18 -0
  10. package/dist/data/default-pricing.d.ts.map +1 -0
  11. package/dist/data/default-pricing.js +307 -0
  12. package/dist/data/default-pricing.js.map +1 -0
  13. package/dist/providers/enhanced-manager.d.ts +2 -1
  14. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  15. package/dist/providers/enhanced-manager.js +20 -2
  16. package/dist/providers/enhanced-manager.js.map +1 -1
  17. package/dist/providers/manager.d.ts +3 -1
  18. package/dist/providers/manager.d.ts.map +1 -1
  19. package/dist/providers/manager.js +12 -1
  20. package/dist/providers/manager.js.map +1 -1
  21. package/dist/server.d.ts +2 -0
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +35 -4
  24. package/dist/server.js.map +1 -1
  25. package/dist/services/pricing.d.ts +56 -0
  26. package/dist/services/pricing.d.ts.map +1 -0
  27. package/dist/services/pricing.js +124 -0
  28. package/dist/services/pricing.js.map +1 -0
  29. package/dist/services/usage.d.ts +48 -0
  30. package/dist/services/usage.d.ts.map +1 -0
  31. package/dist/services/usage.js +243 -0
  32. package/dist/services/usage.js.map +1 -0
  33. package/dist/tools/get-usage-stats.d.ts +8 -0
  34. package/dist/tools/get-usage-stats.d.ts.map +1 -0
  35. package/dist/tools/get-usage-stats.js +92 -0
  36. package/dist/tools/get-usage-stats.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/config/types.ts +51 -0
  39. package/src/data/default-pricing.ts +368 -0
  40. package/src/providers/enhanced-manager.ts +41 -4
  41. package/src/providers/manager.ts +22 -1
  42. package/src/server.ts +42 -4
  43. package/src/services/pricing.ts +155 -0
  44. package/src/services/usage.ts +293 -0
  45. package/src/tools/get-usage-stats.ts +109 -0
  46. package/tests/pricing.test.ts +335 -0
  47. package/tests/tools/get-usage-stats.test.ts +236 -0
  48. 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
+ });