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.
- package/.claude/agents/pricing-updater.md +111 -0
- package/.claude/commands/update-pricing.md +22 -0
- package/.releaserc.json +4 -0
- package/CHANGELOG.md +14 -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/approval.test.ts +440 -0
- package/tests/cache.test.ts +240 -0
- package/tests/config.test.ts +468 -0
- package/tests/consensus.test.ts +10 -0
- package/tests/conversation.test.ts +86 -0
- package/tests/duck-debate.test.ts +105 -1
- package/tests/duck-iterate.test.ts +30 -0
- package/tests/duck-judge.test.ts +93 -0
- package/tests/duck-vote.test.ts +46 -0
- package/tests/health.test.ts +129 -0
- package/tests/pricing.test.ts +335 -0
- package/tests/providers.test.ts +591 -0
- package/tests/safe-logger.test.ts +314 -0
- package/tests/tools/approve-mcp-request.test.ts +239 -0
- package/tests/tools/ask-duck.test.ts +159 -0
- package/tests/tools/chat-duck.test.ts +191 -0
- package/tests/tools/compare-ducks.test.ts +190 -0
- package/tests/tools/duck-council.test.ts +219 -0
- package/tests/tools/get-pending-approvals.test.ts +195 -0
- package/tests/tools/get-usage-stats.test.ts +236 -0
- package/tests/tools/list-ducks.test.ts +144 -0
- package/tests/tools/list-models.test.ts +163 -0
- package/tests/tools/mcp-status.test.ts +330 -0
- 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
|
+
});
|