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
@@ -87,12 +87,39 @@ describe('duckDebateTool', () => {
87
87
  ).rejects.toThrow('Rounds must be between 1 and 10');
88
88
  });
89
89
 
90
- it('should throw error when less than 2 providers', async () => {
90
+ it('should throw error when less than 2 providers specified', async () => {
91
91
  await expect(
92
92
  duckDebateTool(mockProviderManager, { prompt: 'Test', format: 'oxford', providers: ['openai'] })
93
93
  ).rejects.toThrow('At least 2 providers are required');
94
94
  });
95
95
 
96
+ it('should throw error when only 1 provider available total', async () => {
97
+ // Create manager with only 1 provider
98
+ const singleProviderConfig = {
99
+ getConfig: jest.fn().mockReturnValue({
100
+ providers: {
101
+ openai: {
102
+ api_key: 'key1',
103
+ base_url: 'https://api.openai.com/v1',
104
+ default_model: 'gpt-4',
105
+ nickname: 'GPT-4',
106
+ models: ['gpt-4'],
107
+ },
108
+ },
109
+ default_provider: 'openai',
110
+ cache_ttl: 300,
111
+ enable_failover: false,
112
+ default_temperature: 0.7,
113
+ }),
114
+ } as any;
115
+
116
+ const singleProviderManager = new ProviderManager(singleProviderConfig);
117
+
118
+ await expect(
119
+ duckDebateTool(singleProviderManager, { prompt: 'Test', format: 'oxford' })
120
+ ).rejects.toThrow('At least 2 providers are required for a debate');
121
+ });
122
+
96
123
  it('should throw error when provider does not exist', async () => {
97
124
  await expect(
98
125
  duckDebateTool(mockProviderManager, { prompt: 'Test', format: 'oxford', providers: ['openai', 'nonexistent'] })
@@ -283,4 +310,81 @@ describe('duckDebateTool', () => {
283
310
  const text = result.content[0].text;
284
311
  expect(text).toContain('3 rounds completed');
285
312
  });
313
+
314
+ it('should perform multi-round socratic debate', async () => {
315
+ // Round 1: 2 participants
316
+ mockCreate
317
+ .mockResolvedValueOnce({
318
+ choices: [{ message: { content: 'Question round 1' }, finish_reason: 'stop' }],
319
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
320
+ model: 'gpt-4',
321
+ })
322
+ .mockResolvedValueOnce({
323
+ choices: [{ message: { content: 'Response round 1' }, finish_reason: 'stop' }],
324
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
325
+ model: 'gemini-pro',
326
+ })
327
+ // Round 2 - should use "Build on previous responses" prompt
328
+ .mockResolvedValueOnce({
329
+ choices: [{ message: { content: 'Question round 2' }, finish_reason: 'stop' }],
330
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
331
+ model: 'gpt-4',
332
+ })
333
+ .mockResolvedValueOnce({
334
+ choices: [{ message: { content: 'Response round 2' }, finish_reason: 'stop' }],
335
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
336
+ model: 'gemini-pro',
337
+ })
338
+ // Synthesis
339
+ .mockResolvedValueOnce({
340
+ choices: [{ message: { content: 'Socratic synthesis' }, finish_reason: 'stop' }],
341
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
342
+ model: 'gpt-4',
343
+ });
344
+
345
+ const result = await duckDebateTool(mockProviderManager, {
346
+ prompt: 'What is truth?',
347
+ format: 'socratic',
348
+ rounds: 2,
349
+ });
350
+
351
+ const text = result.content[0].text;
352
+ expect(text).toContain('Socratic Debate');
353
+ expect(text).toContain('ROUND 1');
354
+ expect(text).toContain('ROUND 2');
355
+ expect(text).toContain('2 rounds completed');
356
+ });
357
+
358
+ it('should truncate long arguments in display', async () => {
359
+ // Create arguments longer than 800 characters
360
+ const longArgument = 'A'.repeat(900);
361
+
362
+ mockCreate
363
+ .mockResolvedValueOnce({
364
+ choices: [{ message: { content: longArgument }, finish_reason: 'stop' }],
365
+ usage: { prompt_tokens: 10, completion_tokens: 100, total_tokens: 110 },
366
+ model: 'gpt-4',
367
+ })
368
+ .mockResolvedValueOnce({
369
+ choices: [{ message: { content: 'Short con argument' }, finish_reason: 'stop' }],
370
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
371
+ model: 'gemini-pro',
372
+ })
373
+ .mockResolvedValueOnce({
374
+ choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
375
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
376
+ model: 'gpt-4',
377
+ });
378
+
379
+ const result = await duckDebateTool(mockProviderManager, {
380
+ prompt: 'Test',
381
+ format: 'oxford',
382
+ rounds: 1,
383
+ });
384
+
385
+ const text = result.content[0].text;
386
+ expect(text).toContain('[truncated]');
387
+ // Should not contain the full 900 A's
388
+ expect(text).not.toContain('A'.repeat(900));
389
+ });
286
390
  });
@@ -246,4 +246,34 @@ describe('duckIterateTool', () => {
246
246
  expect(mockCreate).toHaveBeenCalledTimes(1);
247
247
  expect(result.content[0].text).toContain('1 rounds completed');
248
248
  });
249
+
250
+ it('should truncate long responses in iteration history', async () => {
251
+ // Create a response longer than 500 characters
252
+ const longResponse = 'A'.repeat(600);
253
+
254
+ mockCreate
255
+ .mockResolvedValueOnce({
256
+ choices: [{ message: { content: longResponse }, finish_reason: 'stop' }],
257
+ usage: { prompt_tokens: 10, completion_tokens: 100, total_tokens: 110 },
258
+ model: 'gpt-4',
259
+ })
260
+ .mockResolvedValueOnce({
261
+ choices: [{ message: { content: 'Short refined response' }, finish_reason: 'stop' }],
262
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
263
+ model: 'gemini-pro',
264
+ });
265
+
266
+ const result = await duckIterateTool(mockProviderManager, {
267
+ prompt: 'Test',
268
+ providers: ['openai', 'gemini'],
269
+ mode: 'refine',
270
+ iterations: 2,
271
+ });
272
+
273
+ const text = result.content[0].text;
274
+ // Should contain truncated indicator for the long response in history
275
+ expect(text).toContain('[truncated]');
276
+ // Final response section should have the short refined response
277
+ expect(text).toContain('Short refined response');
278
+ });
249
279
  });
@@ -293,4 +293,97 @@ describe('duckJudgeTool', () => {
293
293
  expect(text).toContain('gemini');
294
294
  expect(text).toContain('Not evaluated');
295
295
  });
296
+
297
+ it('should throw error when no judge provider available', async () => {
298
+ // Mock getProviderNames to return empty array
299
+ const originalGetProviderNames = mockProviderManager.getProviderNames;
300
+ mockProviderManager.getProviderNames = jest.fn().mockReturnValue([]);
301
+
302
+ await expect(
303
+ duckJudgeTool(mockProviderManager, {
304
+ responses: mockResponses,
305
+ })
306
+ ).rejects.toThrow('No judge provider available');
307
+
308
+ // Restore original
309
+ mockProviderManager.getProviderNames = originalGetProviderNames;
310
+ });
311
+
312
+ it('should handle malformed JSON that throws parse error', async () => {
313
+ // JSON that looks valid but has syntax errors
314
+ mockCreate.mockResolvedValueOnce({
315
+ choices: [{
316
+ message: { content: '{"rankings": [{"provider": "openai", score: invalid}]}' },
317
+ finish_reason: 'stop',
318
+ }],
319
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
320
+ model: 'gpt-4',
321
+ });
322
+
323
+ const result = await duckJudgeTool(mockProviderManager, {
324
+ responses: mockResponses,
325
+ });
326
+
327
+ const text = result.content[0].text;
328
+ // Should use fallback evaluation
329
+ expect(text).toContain('Judge Evaluation');
330
+ expect(text).toContain('Unable to parse');
331
+ });
332
+
333
+ it('should match provider by nickname in rankings', async () => {
334
+ // Use nickname instead of provider name
335
+ const judgeResponse = JSON.stringify({
336
+ rankings: [
337
+ { provider: 'GPT-4', score: 85, justification: 'Good response' },
338
+ { provider: 'Gemini', score: 75, justification: 'Okay response' },
339
+ ],
340
+ summary: 'Matched by nickname.',
341
+ });
342
+
343
+ mockCreate.mockResolvedValueOnce({
344
+ choices: [{
345
+ message: { content: judgeResponse },
346
+ finish_reason: 'stop',
347
+ }],
348
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
349
+ model: 'gpt-4',
350
+ });
351
+
352
+ const result = await duckJudgeTool(mockProviderManager, {
353
+ responses: mockResponses,
354
+ });
355
+
356
+ const text = result.content[0].text;
357
+ expect(text).toContain('85/100');
358
+ expect(text).toContain('75/100');
359
+ });
360
+
361
+ it('should match provider by contained name in rankings', async () => {
362
+ // Use partial name that contains the provider name
363
+ const judgeResponse = JSON.stringify({
364
+ rankings: [
365
+ { provider: 'the openai model', score: 90, justification: 'Excellent' },
366
+ { provider: 'google gemini response', score: 80, justification: 'Good' },
367
+ ],
368
+ summary: 'Matched by contained name.',
369
+ });
370
+
371
+ mockCreate.mockResolvedValueOnce({
372
+ choices: [{
373
+ message: { content: judgeResponse },
374
+ finish_reason: 'stop',
375
+ }],
376
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
377
+ model: 'gpt-4',
378
+ });
379
+
380
+ const result = await duckJudgeTool(mockProviderManager, {
381
+ responses: mockResponses,
382
+ });
383
+
384
+ const text = result.content[0].text;
385
+ // Should successfully match the providers
386
+ expect(text).toContain('90/100');
387
+ expect(text).toContain('80/100');
388
+ });
296
389
  });
@@ -247,4 +247,50 @@ describe('duckVoteTool', () => {
247
247
 
248
248
  expect(result.content[0].text).toContain('Option A');
249
249
  });
250
+
251
+ it('should throw error when no voters available', async () => {
252
+ // Mock getProviderNames to return empty array
253
+ const originalGetProviderNames = mockProviderManager.getProviderNames;
254
+ mockProviderManager.getProviderNames = jest.fn().mockReturnValue([]);
255
+
256
+ await expect(
257
+ duckVoteTool(mockProviderManager, {
258
+ question: 'Test?',
259
+ options: ['Option A', 'Option B'],
260
+ })
261
+ ).rejects.toThrow('No voters available');
262
+
263
+ // Restore original
264
+ mockProviderManager.getProviderNames = originalGetProviderNames;
265
+ });
266
+
267
+ it('should handle case when no valid votes result in no winner', async () => {
268
+ // Both responses don't mention any valid option
269
+ mockCreate
270
+ .mockResolvedValueOnce({
271
+ choices: [{
272
+ message: { content: 'I cannot decide between these options.' },
273
+ finish_reason: 'stop',
274
+ }],
275
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
276
+ model: 'gpt-4',
277
+ })
278
+ .mockResolvedValueOnce({
279
+ choices: [{
280
+ message: { content: 'These options are not comparable.' },
281
+ finish_reason: 'stop',
282
+ }],
283
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
284
+ model: 'gemini-pro',
285
+ });
286
+
287
+ const result = await duckVoteTool(mockProviderManager, {
288
+ question: 'Test?',
289
+ options: ['Option A', 'Option B'],
290
+ });
291
+
292
+ const text = result.content[0].text;
293
+ expect(text).toContain('No valid votes');
294
+ expect(text).toContain('0/2 valid votes');
295
+ });
250
296
  });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { HealthMonitor } from '../src/services/health.js';
3
+ import { ProviderManager } from '../src/providers/manager.js';
4
+ import { ProviderHealth } from '../src/config/types.js';
5
+
6
+ // Mock dependencies
7
+ jest.mock('../src/utils/logger');
8
+ jest.mock('../src/providers/manager.js');
9
+
10
+ describe('HealthMonitor', () => {
11
+ let healthMonitor: HealthMonitor;
12
+ let mockProviderManager: jest.Mocked<ProviderManager>;
13
+
14
+ beforeEach(() => {
15
+ // Create a mock ProviderManager
16
+ mockProviderManager = {
17
+ checkHealth: jest.fn(),
18
+ } as unknown as jest.Mocked<ProviderManager>;
19
+
20
+ healthMonitor = new HealthMonitor(mockProviderManager);
21
+ });
22
+
23
+ describe('performHealthChecks', () => {
24
+ it('should call providerManager.checkHealth and return results', async () => {
25
+ const healthResults: ProviderHealth[] = [
26
+ {
27
+ provider: 'openai',
28
+ healthy: true,
29
+ latency: 150,
30
+ lastCheck: new Date(),
31
+ },
32
+ {
33
+ provider: 'groq',
34
+ healthy: true,
35
+ latency: 80,
36
+ lastCheck: new Date(),
37
+ },
38
+ ];
39
+
40
+ mockProviderManager.checkHealth.mockResolvedValue(healthResults);
41
+
42
+ const results = await healthMonitor.performHealthChecks();
43
+
44
+ expect(mockProviderManager.checkHealth).toHaveBeenCalledTimes(1);
45
+ expect(results).toEqual(healthResults);
46
+ expect(results).toHaveLength(2);
47
+ });
48
+
49
+ it('should handle unhealthy providers with errors', async () => {
50
+ const healthResults: ProviderHealth[] = [
51
+ {
52
+ provider: 'openai',
53
+ healthy: true,
54
+ latency: 150,
55
+ lastCheck: new Date(),
56
+ },
57
+ {
58
+ provider: 'groq',
59
+ healthy: false,
60
+ lastCheck: new Date(),
61
+ error: 'Connection refused',
62
+ },
63
+ ];
64
+
65
+ mockProviderManager.checkHealth.mockResolvedValue(healthResults);
66
+
67
+ const results = await healthMonitor.performHealthChecks();
68
+
69
+ expect(results).toHaveLength(2);
70
+ expect(results[0].healthy).toBe(true);
71
+ expect(results[1].healthy).toBe(false);
72
+ expect(results[1].error).toBe('Connection refused');
73
+ });
74
+
75
+ it('should handle all unhealthy providers', async () => {
76
+ const healthResults: ProviderHealth[] = [
77
+ {
78
+ provider: 'openai',
79
+ healthy: false,
80
+ lastCheck: new Date(),
81
+ error: 'API key invalid',
82
+ },
83
+ {
84
+ provider: 'groq',
85
+ healthy: false,
86
+ lastCheck: new Date(),
87
+ error: 'Timeout',
88
+ },
89
+ ];
90
+
91
+ mockProviderManager.checkHealth.mockResolvedValue(healthResults);
92
+
93
+ const results = await healthMonitor.performHealthChecks();
94
+
95
+ expect(results.every((r) => !r.healthy)).toBe(true);
96
+ });
97
+
98
+ it('should handle empty provider list', async () => {
99
+ mockProviderManager.checkHealth.mockResolvedValue([]);
100
+
101
+ const results = await healthMonitor.performHealthChecks();
102
+
103
+ expect(results).toEqual([]);
104
+ });
105
+
106
+ it('should handle providers without latency info', async () => {
107
+ const healthResults: ProviderHealth[] = [
108
+ {
109
+ provider: 'openai',
110
+ healthy: false,
111
+ lastCheck: new Date(),
112
+ error: 'Failed before timing',
113
+ },
114
+ ];
115
+
116
+ mockProviderManager.checkHealth.mockResolvedValue(healthResults);
117
+
118
+ const results = await healthMonitor.performHealthChecks();
119
+
120
+ expect(results[0].latency).toBeUndefined();
121
+ });
122
+
123
+ it('should propagate errors from checkHealth', async () => {
124
+ mockProviderManager.checkHealth.mockRejectedValue(new Error('Network error'));
125
+
126
+ await expect(healthMonitor.performHealthChecks()).rejects.toThrow('Network error');
127
+ });
128
+ });
129
+ });