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,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
+ });