mcp-rubber-duck 1.5.1 → 1.6.0

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 (69) hide show
  1. package/.claude/agents/pricing-updater.md +111 -0
  2. package/.claude/commands/update-pricing.md +22 -0
  3. package/.releaserc.json +4 -0
  4. package/CHANGELOG.md +14 -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/approval.test.ts +440 -0
  47. package/tests/cache.test.ts +240 -0
  48. package/tests/config.test.ts +468 -0
  49. package/tests/consensus.test.ts +10 -0
  50. package/tests/conversation.test.ts +86 -0
  51. package/tests/duck-debate.test.ts +105 -1
  52. package/tests/duck-iterate.test.ts +30 -0
  53. package/tests/duck-judge.test.ts +93 -0
  54. package/tests/duck-vote.test.ts +46 -0
  55. package/tests/health.test.ts +129 -0
  56. package/tests/pricing.test.ts +335 -0
  57. package/tests/providers.test.ts +591 -0
  58. package/tests/safe-logger.test.ts +314 -0
  59. package/tests/tools/approve-mcp-request.test.ts +239 -0
  60. package/tests/tools/ask-duck.test.ts +159 -0
  61. package/tests/tools/chat-duck.test.ts +191 -0
  62. package/tests/tools/compare-ducks.test.ts +190 -0
  63. package/tests/tools/duck-council.test.ts +219 -0
  64. package/tests/tools/get-pending-approvals.test.ts +195 -0
  65. package/tests/tools/get-usage-stats.test.ts +236 -0
  66. package/tests/tools/list-ducks.test.ts +144 -0
  67. package/tests/tools/list-models.test.ts +163 -0
  68. package/tests/tools/mcp-status.test.ts +330 -0
  69. package/tests/usage.test.ts +661 -0
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { duckCouncilTool } from '../../src/tools/duck-council.js';
3
+ import { ProviderManager } from '../../src/providers/manager.js';
4
+
5
+ // Mock dependencies
6
+ jest.mock('../../src/utils/logger');
7
+ jest.mock('../../src/providers/manager.js');
8
+
9
+ describe('duckCouncilTool', () => {
10
+ let mockProviderManager: jest.Mocked<ProviderManager>;
11
+
12
+ const mockResponses = [
13
+ {
14
+ provider: 'openai',
15
+ nickname: 'OpenAI Duck',
16
+ content: 'I think TypeScript is great for large projects.',
17
+ model: 'gpt-4',
18
+ latency: 150,
19
+ usage: { prompt_tokens: 10, completion_tokens: 25, total_tokens: 35 },
20
+ },
21
+ {
22
+ provider: 'groq',
23
+ nickname: 'Groq Duck',
24
+ content: 'TypeScript adds type safety which prevents many bugs.',
25
+ model: 'llama-3.1-70b',
26
+ latency: 80,
27
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
28
+ },
29
+ ];
30
+
31
+ beforeEach(() => {
32
+ mockProviderManager = {
33
+ getProviderNames: jest.fn().mockReturnValue(['openai', 'groq']),
34
+ duckCouncil: jest.fn().mockResolvedValue(mockResponses),
35
+ } as unknown as jest.Mocked<ProviderManager>;
36
+ });
37
+
38
+ it('should throw error when prompt is missing', async () => {
39
+ await expect(duckCouncilTool(mockProviderManager, {})).rejects.toThrow(
40
+ 'Prompt is required for the duck council'
41
+ );
42
+ });
43
+
44
+ it('should throw error when no providers available', async () => {
45
+ mockProviderManager.getProviderNames.mockReturnValue([]);
46
+
47
+ await expect(
48
+ duckCouncilTool(mockProviderManager, { prompt: 'Test' })
49
+ ).rejects.toThrow('No ducks available for the council!');
50
+ });
51
+
52
+ it('should convene duck council with prompt', async () => {
53
+ const result = await duckCouncilTool(mockProviderManager, {
54
+ prompt: 'What is TypeScript?',
55
+ });
56
+
57
+ expect(mockProviderManager.duckCouncil).toHaveBeenCalledWith('What is TypeScript?', {
58
+ model: undefined,
59
+ });
60
+ expect(result.content).toHaveLength(1);
61
+ expect(result.content[0].type).toBe('text');
62
+ });
63
+
64
+ it('should display council topic', async () => {
65
+ const result = await duckCouncilTool(mockProviderManager, {
66
+ prompt: 'What is TypeScript?',
67
+ });
68
+
69
+ expect(result.content[0].text).toContain('Duck Council Topic');
70
+ expect(result.content[0].text).toContain('What is TypeScript?');
71
+ });
72
+
73
+ it('should display number of ducks in attendance', async () => {
74
+ const result = await duckCouncilTool(mockProviderManager, {
75
+ prompt: 'Test',
76
+ });
77
+
78
+ expect(result.content[0].text).toContain('2 ducks in attendance');
79
+ });
80
+
81
+ it('should display each duck response with number', async () => {
82
+ const result = await duckCouncilTool(mockProviderManager, {
83
+ prompt: 'Test',
84
+ });
85
+
86
+ expect(result.content[0].text).toContain('Duck #1: OpenAI Duck');
87
+ expect(result.content[0].text).toContain('Duck #2: Groq Duck');
88
+ expect(result.content[0].text).toContain('TypeScript is great');
89
+ expect(result.content[0].text).toContain('type safety');
90
+ });
91
+
92
+ it('should display model and latency metadata', async () => {
93
+ const result = await duckCouncilTool(mockProviderManager, {
94
+ prompt: 'Test',
95
+ });
96
+
97
+ expect(result.content[0].text).toContain('gpt-4');
98
+ expect(result.content[0].text).toContain('150ms');
99
+ expect(result.content[0].text).toContain('35 tokens');
100
+ });
101
+
102
+ it('should handle error responses from ducks', async () => {
103
+ mockProviderManager.duckCouncil.mockResolvedValue([
104
+ mockResponses[0],
105
+ {
106
+ provider: 'groq',
107
+ nickname: 'Groq Duck',
108
+ content: 'Error: API key invalid',
109
+ model: '',
110
+ latency: 0,
111
+ },
112
+ ]);
113
+
114
+ const result = await duckCouncilTool(mockProviderManager, {
115
+ prompt: 'Test',
116
+ });
117
+
118
+ expect(result.content[0].text).toContain('Duck had to leave early');
119
+ expect(result.content[0].text).toContain('API key invalid');
120
+ });
121
+
122
+ it('should show council summary with success count', async () => {
123
+ const result = await duckCouncilTool(mockProviderManager, {
124
+ prompt: 'Test',
125
+ });
126
+
127
+ expect(result.content[0].text).toContain('Council Summary');
128
+ expect(result.content[0].text).toContain('2/2 ducks provided their wisdom');
129
+ });
130
+
131
+ it('should show partial council message when some fail', async () => {
132
+ mockProviderManager.duckCouncil.mockResolvedValue([
133
+ mockResponses[0],
134
+ {
135
+ provider: 'groq',
136
+ nickname: 'Groq Duck',
137
+ content: 'Error: Timeout',
138
+ model: '',
139
+ latency: 0,
140
+ },
141
+ ]);
142
+
143
+ const result = await duckCouncilTool(mockProviderManager, {
144
+ prompt: 'Test',
145
+ });
146
+
147
+ expect(result.content[0].text).toContain('1/2 ducks provided their wisdom');
148
+ expect(result.content[0].text).toContain('Partial council');
149
+ });
150
+
151
+ it('should pass model option', async () => {
152
+ await duckCouncilTool(mockProviderManager, {
153
+ prompt: 'Test',
154
+ model: 'gpt-3.5-turbo',
155
+ });
156
+
157
+ expect(mockProviderManager.duckCouncil).toHaveBeenCalledWith('Test', {
158
+ model: 'gpt-3.5-turbo',
159
+ });
160
+ });
161
+
162
+ it('should not show latency when zero', async () => {
163
+ mockProviderManager.duckCouncil.mockResolvedValue([
164
+ {
165
+ ...mockResponses[0],
166
+ latency: 0,
167
+ },
168
+ ]);
169
+
170
+ const result = await duckCouncilTool(mockProviderManager, {
171
+ prompt: 'Test',
172
+ });
173
+
174
+ // Should not show "0ms" in output
175
+ expect(result.content[0].text).not.toContain('0ms');
176
+ });
177
+
178
+ it('should not show tokens when usage missing', async () => {
179
+ mockProviderManager.duckCouncil.mockResolvedValue([
180
+ {
181
+ ...mockResponses[0],
182
+ usage: undefined,
183
+ },
184
+ ]);
185
+
186
+ const result = await duckCouncilTool(mockProviderManager, {
187
+ prompt: 'Test',
188
+ });
189
+
190
+ expect(result.content[0].text).not.toContain('tokens');
191
+ });
192
+
193
+ it('should show error message when all ducks fail', async () => {
194
+ mockProviderManager.duckCouncil.mockResolvedValue([
195
+ {
196
+ provider: 'openai',
197
+ nickname: 'OpenAI Duck',
198
+ content: 'Error: Connection timeout',
199
+ model: '',
200
+ latency: 0,
201
+ },
202
+ {
203
+ provider: 'groq',
204
+ nickname: 'Groq Duck',
205
+ content: 'Error: API error',
206
+ model: '',
207
+ latency: 0,
208
+ },
209
+ ]);
210
+
211
+ const result = await duckCouncilTool(mockProviderManager, {
212
+ prompt: 'Test',
213
+ });
214
+
215
+ expect(result.content[0].text).toContain('0/2 ducks provided their wisdom');
216
+ // Should not show partial council message
217
+ expect(result.content[0].text).not.toContain('Partial council');
218
+ });
219
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { getPendingApprovalsTool } from '../../src/tools/get-pending-approvals.js';
3
+ import { ApprovalService } from '../../src/services/approval.js';
4
+
5
+ // Mock dependencies
6
+ jest.mock('../../src/utils/logger');
7
+ jest.mock('../../src/services/approval.js');
8
+
9
+ describe('getPendingApprovalsTool', () => {
10
+ let mockApprovalService: jest.Mocked<ApprovalService>;
11
+
12
+ const now = Date.now();
13
+ const mockApprovals = [
14
+ {
15
+ id: 'approval-1',
16
+ duckName: 'OpenAI Duck',
17
+ mcpServer: 'filesystem',
18
+ toolName: 'read_file',
19
+ arguments: { path: '/tmp/test.txt' },
20
+ status: 'pending' as const,
21
+ timestamp: now - 30000, // 30 seconds ago
22
+ expiresAt: now + 30000, // expires in 30 seconds
23
+ },
24
+ {
25
+ id: 'approval-2',
26
+ duckName: 'Groq Duck',
27
+ mcpServer: 'database',
28
+ toolName: 'query',
29
+ arguments: { sql: 'SELECT * FROM users' },
30
+ status: 'pending' as const,
31
+ timestamp: now - 10000, // 10 seconds ago
32
+ expiresAt: now + 50000, // expires in 50 seconds
33
+ },
34
+ ];
35
+
36
+ beforeEach(() => {
37
+ mockApprovalService = {
38
+ getPendingApprovals: jest.fn(),
39
+ } as unknown as jest.Mocked<ApprovalService>;
40
+ });
41
+
42
+ describe('no pending approvals', () => {
43
+ it('should return success message when no approvals exist', () => {
44
+ mockApprovalService.getPendingApprovals.mockReturnValue([]);
45
+
46
+ const result = getPendingApprovalsTool(mockApprovalService, {});
47
+
48
+ expect(result.content[0].text).toContain('No pending MCP tool approvals');
49
+ expect(result.isError).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe('with pending approvals', () => {
54
+ beforeEach(() => {
55
+ mockApprovalService.getPendingApprovals.mockReturnValue(mockApprovals);
56
+ });
57
+
58
+ it('should list all pending approvals', () => {
59
+ const result = getPendingApprovalsTool(mockApprovalService, {});
60
+
61
+ expect(result.content[0].text).toContain('2 pending MCP approvals');
62
+ expect(result.content[0].text).toContain('OpenAI Duck');
63
+ expect(result.content[0].text).toContain('Groq Duck');
64
+ expect(result.content[0].text).toContain('filesystem:read_file');
65
+ expect(result.content[0].text).toContain('database:query');
66
+ });
67
+
68
+ it('should display approval IDs', () => {
69
+ const result = getPendingApprovalsTool(mockApprovalService, {});
70
+
71
+ expect(result.content[0].text).toContain('approval-1');
72
+ expect(result.content[0].text).toContain('approval-2');
73
+ });
74
+
75
+ it('should display arguments for small payloads', () => {
76
+ const result = getPendingApprovalsTool(mockApprovalService, {});
77
+
78
+ expect(result.content[0].text).toContain('/tmp/test.txt');
79
+ expect(result.content[0].text).toContain('SELECT * FROM users');
80
+ });
81
+
82
+ it('should show parameter count for large arguments', () => {
83
+ mockApprovalService.getPendingApprovals.mockReturnValue([
84
+ {
85
+ ...mockApprovals[0],
86
+ arguments: {
87
+ param1: 'a'.repeat(50),
88
+ param2: 'b'.repeat(50),
89
+ param3: 'c'.repeat(50),
90
+ },
91
+ },
92
+ ]);
93
+
94
+ const result = getPendingApprovalsTool(mockApprovalService, {});
95
+
96
+ expect(result.content[0].text).toContain('3 parameters');
97
+ });
98
+
99
+ it('should include usage hint', () => {
100
+ const result = getPendingApprovalsTool(mockApprovalService, {});
101
+
102
+ expect(result.content[0].text).toContain('approve_mcp_request');
103
+ });
104
+
105
+ it('should handle singular approval text', () => {
106
+ mockApprovalService.getPendingApprovals.mockReturnValue([mockApprovals[0]]);
107
+
108
+ const result = getPendingApprovalsTool(mockApprovalService, {});
109
+
110
+ expect(result.content[0].text).toContain('1 pending MCP approval:');
111
+ expect(result.content[0].text).not.toContain('approvals:');
112
+ });
113
+ });
114
+
115
+ describe('filtering by duck', () => {
116
+ beforeEach(() => {
117
+ mockApprovalService.getPendingApprovals.mockReturnValue(mockApprovals);
118
+ });
119
+
120
+ it('should filter approvals by duck name', () => {
121
+ const result = getPendingApprovalsTool(mockApprovalService, {
122
+ duck: 'OpenAI Duck',
123
+ });
124
+
125
+ expect(result.content[0].text).toContain('OpenAI Duck');
126
+ expect(result.content[0].text).not.toContain('Groq Duck');
127
+ expect(result.content[0].text).toContain('1 pending MCP approval');
128
+ });
129
+
130
+ it('should return no approvals when duck filter has no matches', () => {
131
+ const result = getPendingApprovalsTool(mockApprovalService, {
132
+ duck: 'NonexistentDuck',
133
+ });
134
+
135
+ expect(result.content[0].text).toContain('No pending MCP tool approvals');
136
+ });
137
+ });
138
+
139
+ describe('time formatting', () => {
140
+ it('should show time since request', () => {
141
+ mockApprovalService.getPendingApprovals.mockReturnValue([mockApprovals[0]]);
142
+
143
+ const result = getPendingApprovalsTool(mockApprovalService, {});
144
+
145
+ // Approval was 30 seconds ago
146
+ expect(result.content[0].text).toMatch(/Requested: \d+s ago/);
147
+ });
148
+
149
+ it('should show expiration time', () => {
150
+ mockApprovalService.getPendingApprovals.mockReturnValue([mockApprovals[0]]);
151
+
152
+ const result = getPendingApprovalsTool(mockApprovalService, {});
153
+
154
+ expect(result.content[0].text).toMatch(/Expires: \d+s/);
155
+ });
156
+
157
+ it('should show expired status for expired approvals', () => {
158
+ mockApprovalService.getPendingApprovals.mockReturnValue([
159
+ {
160
+ ...mockApprovals[0],
161
+ expiresAt: now - 1000, // Already expired
162
+ },
163
+ ]);
164
+
165
+ const result = getPendingApprovalsTool(mockApprovalService, {});
166
+
167
+ expect(result.content[0].text).toContain('Expires: expired');
168
+ });
169
+ });
170
+
171
+ describe('error handling', () => {
172
+ it('should handle exceptions gracefully', () => {
173
+ mockApprovalService.getPendingApprovals.mockImplementation(() => {
174
+ throw new Error('Service unavailable');
175
+ });
176
+
177
+ const result = getPendingApprovalsTool(mockApprovalService, {});
178
+
179
+ expect(result.isError).toBe(true);
180
+ expect(result.content[0].text).toContain('Failed to get pending approvals');
181
+ expect(result.content[0].text).toContain('Service unavailable');
182
+ });
183
+
184
+ it('should handle non-Error exceptions', () => {
185
+ mockApprovalService.getPendingApprovals.mockImplementation(() => {
186
+ throw 'Unknown error';
187
+ });
188
+
189
+ const result = getPendingApprovalsTool(mockApprovalService, {});
190
+
191
+ expect(result.isError).toBe(true);
192
+ expect(result.content[0].text).toContain('Unknown error');
193
+ });
194
+ });
195
+ });
@@ -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
+ });