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
|
@@ -2,6 +2,7 @@ import { EnhancedDuckProvider } from './duck-provider-enhanced.js';
|
|
|
2
2
|
import { ProviderManager } from './manager.js';
|
|
3
3
|
import { ConfigManager } from '../config/config.js';
|
|
4
4
|
import { FunctionBridge } from '../services/function-bridge.js';
|
|
5
|
+
import { UsageService } from '../services/usage.js';
|
|
5
6
|
import { DuckResponse } from '../config/types.js';
|
|
6
7
|
import { ChatOptions, MCPResult } from './types.js';
|
|
7
8
|
import { logger } from '../utils/logger.js';
|
|
@@ -11,12 +12,12 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
11
12
|
private functionBridge?: FunctionBridge;
|
|
12
13
|
private mcpEnabled: boolean = false;
|
|
13
14
|
|
|
14
|
-
constructor(configManager: ConfigManager, functionBridge?: FunctionBridge) {
|
|
15
|
-
super(configManager);
|
|
15
|
+
constructor(configManager: ConfigManager, functionBridge?: FunctionBridge, usageService?: UsageService) {
|
|
16
|
+
super(configManager, usageService);
|
|
16
17
|
this.functionBridge = functionBridge;
|
|
17
|
-
this.mcpEnabled = !!functionBridge &&
|
|
18
|
+
this.mcpEnabled = !!functionBridge &&
|
|
18
19
|
(configManager.getConfig().mcp_bridge?.enabled || false);
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
if (this.mcpEnabled) {
|
|
21
22
|
this.initializeEnhancedProviders();
|
|
22
23
|
}
|
|
@@ -91,6 +92,7 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
91
92
|
|
|
92
93
|
const provider = this.getEnhancedProvider(providerName);
|
|
93
94
|
const startTime = Date.now();
|
|
95
|
+
const modelToUse = options?.model || provider.getInfo().model;
|
|
94
96
|
|
|
95
97
|
try {
|
|
96
98
|
const response = await provider.chat({
|
|
@@ -98,6 +100,18 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
98
100
|
...options,
|
|
99
101
|
});
|
|
100
102
|
|
|
103
|
+
// Record usage
|
|
104
|
+
if (this.usageService && response.usage) {
|
|
105
|
+
this.usageService.recordUsage(
|
|
106
|
+
provider.name,
|
|
107
|
+
response.model,
|
|
108
|
+
response.usage.promptTokens,
|
|
109
|
+
response.usage.completionTokens,
|
|
110
|
+
false,
|
|
111
|
+
false
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
return {
|
|
102
116
|
provider: provider.name,
|
|
103
117
|
nickname: provider.nickname,
|
|
@@ -117,6 +131,11 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
117
131
|
mcpResults: response.mcpResults,
|
|
118
132
|
};
|
|
119
133
|
} catch (error: unknown) {
|
|
134
|
+
// Record error
|
|
135
|
+
if (this.usageService) {
|
|
136
|
+
this.usageService.recordUsage(provider.name, modelToUse, 0, 0, false, true);
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
// Try failover if enabled
|
|
121
140
|
if (this.configManager.getConfig().enable_failover && providerName === undefined) {
|
|
122
141
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -208,6 +227,7 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
208
227
|
|
|
209
228
|
const provider = this.getEnhancedProvider(providerName);
|
|
210
229
|
const startTime = Date.now();
|
|
230
|
+
const modelToUse = options?.model || provider.getInfo().model;
|
|
211
231
|
|
|
212
232
|
try {
|
|
213
233
|
const response = await provider.retryWithApproval(
|
|
@@ -219,6 +239,18 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
219
239
|
}
|
|
220
240
|
);
|
|
221
241
|
|
|
242
|
+
// Record usage
|
|
243
|
+
if (this.usageService && response.usage) {
|
|
244
|
+
this.usageService.recordUsage(
|
|
245
|
+
provider.name,
|
|
246
|
+
response.model,
|
|
247
|
+
response.usage.promptTokens,
|
|
248
|
+
response.usage.completionTokens,
|
|
249
|
+
false,
|
|
250
|
+
false
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
return {
|
|
223
255
|
provider: provider.name,
|
|
224
256
|
nickname: provider.nickname,
|
|
@@ -238,6 +270,11 @@ export class EnhancedProviderManager extends ProviderManager {
|
|
|
238
270
|
mcpResults: response.mcpResults,
|
|
239
271
|
};
|
|
240
272
|
} catch (error: unknown) {
|
|
273
|
+
// Record error
|
|
274
|
+
if (this.usageService) {
|
|
275
|
+
this.usageService.recordUsage(provider.name, modelToUse, 0, 0, false, true);
|
|
276
|
+
}
|
|
277
|
+
|
|
241
278
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
242
279
|
throw new Error(`Failed to retry with approval: ${errorMessage}`);
|
|
243
280
|
}
|
package/src/providers/manager.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DuckProvider } from './provider.js';
|
|
|
2
2
|
import { ConfigManager } from '../config/config.js';
|
|
3
3
|
import { ProviderHealth, DuckResponse } from '../config/types.js';
|
|
4
4
|
import { ChatOptions, ModelInfo } from './types.js';
|
|
5
|
+
import { UsageService } from '../services/usage.js';
|
|
5
6
|
import { logger } from '../utils/logger.js';
|
|
6
7
|
import { getRandomDuckMessage } from '../utils/ascii-art.js';
|
|
7
8
|
|
|
@@ -9,10 +10,12 @@ export class ProviderManager {
|
|
|
9
10
|
private providers: Map<string, DuckProvider> = new Map();
|
|
10
11
|
private healthStatus: Map<string, ProviderHealth> = new Map();
|
|
11
12
|
protected configManager: ConfigManager;
|
|
13
|
+
protected usageService?: UsageService;
|
|
12
14
|
private defaultProvider?: string;
|
|
13
15
|
|
|
14
|
-
constructor(configManager: ConfigManager) {
|
|
16
|
+
constructor(configManager: ConfigManager, usageService?: UsageService) {
|
|
15
17
|
this.configManager = configManager;
|
|
18
|
+
this.usageService = usageService;
|
|
16
19
|
this.initializeProviders();
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -91,6 +94,7 @@ export class ProviderManager {
|
|
|
91
94
|
): Promise<DuckResponse> {
|
|
92
95
|
const provider = this.getProvider(providerName);
|
|
93
96
|
const startTime = Date.now();
|
|
97
|
+
const modelToUse = options?.model || provider.getInfo().model;
|
|
94
98
|
|
|
95
99
|
try {
|
|
96
100
|
const response = await provider.chat({
|
|
@@ -98,6 +102,18 @@ export class ProviderManager {
|
|
|
98
102
|
...options,
|
|
99
103
|
});
|
|
100
104
|
|
|
105
|
+
// Record usage
|
|
106
|
+
if (this.usageService && response.usage) {
|
|
107
|
+
this.usageService.recordUsage(
|
|
108
|
+
provider.name,
|
|
109
|
+
response.model,
|
|
110
|
+
response.usage.promptTokens,
|
|
111
|
+
response.usage.completionTokens,
|
|
112
|
+
false,
|
|
113
|
+
false
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
101
117
|
return {
|
|
102
118
|
provider: provider.name,
|
|
103
119
|
nickname: provider.nickname,
|
|
@@ -115,6 +131,11 @@ export class ProviderManager {
|
|
|
115
131
|
cached: false,
|
|
116
132
|
};
|
|
117
133
|
} catch (error: unknown) {
|
|
134
|
+
// Record error
|
|
135
|
+
if (this.usageService) {
|
|
136
|
+
this.usageService.recordUsage(provider.name, modelToUse, 0, 0, false, true);
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
// Try failover if enabled
|
|
119
140
|
if (this.configManager.getConfig().enable_failover && providerName === undefined) {
|
|
120
141
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
package/src/server.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { ConversationManager } from './services/conversation.js';
|
|
|
13
13
|
import { ResponseCache } from './services/cache.js';
|
|
14
14
|
import { HealthMonitor } from './services/health.js';
|
|
15
15
|
import { MCPClientManager } from './services/mcp-client-manager.js';
|
|
16
|
+
import { PricingService } from './services/pricing.js';
|
|
17
|
+
import { UsageService } from './services/usage.js';
|
|
16
18
|
import { DuckResponse } from './config/types.js';
|
|
17
19
|
import { ApprovalService } from './services/approval.js';
|
|
18
20
|
import { FunctionBridge } from './services/function-bridge.js';
|
|
@@ -37,9 +39,14 @@ import { getPendingApprovalsTool } from './tools/get-pending-approvals.js';
|
|
|
37
39
|
import { approveMCPRequestTool } from './tools/approve-mcp-request.js';
|
|
38
40
|
import { mcpStatusTool } from './tools/mcp-status.js';
|
|
39
41
|
|
|
42
|
+
// Import usage stats tool
|
|
43
|
+
import { getUsageStatsTool } from './tools/get-usage-stats.js';
|
|
44
|
+
|
|
40
45
|
export class RubberDuckServer {
|
|
41
46
|
private server: Server;
|
|
42
47
|
private configManager: ConfigManager;
|
|
48
|
+
private pricingService: PricingService;
|
|
49
|
+
private usageService: UsageService;
|
|
43
50
|
private providerManager: ProviderManager;
|
|
44
51
|
private enhancedProviderManager?: EnhancedProviderManager;
|
|
45
52
|
private conversationManager: ConversationManager;
|
|
@@ -67,9 +74,16 @@ export class RubberDuckServer {
|
|
|
67
74
|
|
|
68
75
|
// Initialize managers
|
|
69
76
|
this.configManager = new ConfigManager();
|
|
70
|
-
|
|
77
|
+
const config = this.configManager.getConfig();
|
|
78
|
+
|
|
79
|
+
// Initialize pricing and usage services
|
|
80
|
+
this.pricingService = new PricingService(config.pricing);
|
|
81
|
+
this.usageService = new UsageService(this.pricingService);
|
|
82
|
+
|
|
83
|
+
// Initialize provider manager with usage tracking
|
|
84
|
+
this.providerManager = new ProviderManager(this.configManager, this.usageService);
|
|
71
85
|
this.conversationManager = new ConversationManager();
|
|
72
|
-
this.cache = new ResponseCache(
|
|
86
|
+
this.cache = new ResponseCache(config.cache_ttl);
|
|
73
87
|
this.healthMonitor = new HealthMonitor(this.providerManager);
|
|
74
88
|
|
|
75
89
|
// Initialize MCP bridge if enabled
|
|
@@ -105,10 +119,11 @@ export class RubberDuckServer {
|
|
|
105
119
|
mcpConfig.trusted_tools_by_server || {}
|
|
106
120
|
);
|
|
107
121
|
|
|
108
|
-
// Initialize enhanced provider manager
|
|
122
|
+
// Initialize enhanced provider manager with usage tracking
|
|
109
123
|
this.enhancedProviderManager = new EnhancedProviderManager(
|
|
110
124
|
this.configManager,
|
|
111
|
-
this.functionBridge
|
|
125
|
+
this.functionBridge,
|
|
126
|
+
this.usageService
|
|
112
127
|
);
|
|
113
128
|
|
|
114
129
|
this.mcpEnabled = true;
|
|
@@ -178,6 +193,10 @@ export class RubberDuckServer {
|
|
|
178
193
|
case 'duck_debate':
|
|
179
194
|
return await duckDebateTool(this.providerManager, args || {});
|
|
180
195
|
|
|
196
|
+
// Usage stats tool
|
|
197
|
+
case 'get_usage_stats':
|
|
198
|
+
return getUsageStatsTool(this.usageService, args || {});
|
|
199
|
+
|
|
181
200
|
// MCP-specific tools
|
|
182
201
|
case 'get_pending_approvals':
|
|
183
202
|
if (!this.approvalService) {
|
|
@@ -641,6 +660,22 @@ export class RubberDuckServer {
|
|
|
641
660
|
required: ['prompt', 'format'],
|
|
642
661
|
},
|
|
643
662
|
},
|
|
663
|
+
{
|
|
664
|
+
name: 'get_usage_stats',
|
|
665
|
+
description:
|
|
666
|
+
'Get usage statistics for a time period. Shows token counts and costs (when pricing configured).',
|
|
667
|
+
inputSchema: {
|
|
668
|
+
type: 'object',
|
|
669
|
+
properties: {
|
|
670
|
+
period: {
|
|
671
|
+
type: 'string',
|
|
672
|
+
enum: ['today', '7d', '30d', 'all'],
|
|
673
|
+
default: 'today',
|
|
674
|
+
description: 'Time period for stats',
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
},
|
|
644
679
|
];
|
|
645
680
|
|
|
646
681
|
// Add MCP-specific tools if enabled
|
|
@@ -732,6 +767,9 @@ export class RubberDuckServer {
|
|
|
732
767
|
}
|
|
733
768
|
|
|
734
769
|
async stop() {
|
|
770
|
+
// Cleanup usage service (flush pending writes)
|
|
771
|
+
this.usageService.shutdown();
|
|
772
|
+
|
|
735
773
|
// Cleanup MCP resources
|
|
736
774
|
if (this.approvalService) {
|
|
737
775
|
this.approvalService.shutdown();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ModelPricing, PricingConfig } from '../config/types.js';
|
|
2
|
+
import { DEFAULT_PRICING } from '../data/default-pricing.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export interface CostCalculation {
|
|
6
|
+
inputCost: number;
|
|
7
|
+
outputCost: number;
|
|
8
|
+
totalCost: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provider name aliases.
|
|
13
|
+
* Maps common user-provided names to canonical provider names in pricing data.
|
|
14
|
+
*/
|
|
15
|
+
const PROVIDER_ALIASES: Record<string, string> = {
|
|
16
|
+
gemini: 'google',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* PricingService manages token pricing data.
|
|
21
|
+
*
|
|
22
|
+
* It merges hardcoded default pricing with optional user config overrides.
|
|
23
|
+
* User overrides take precedence over defaults.
|
|
24
|
+
*/
|
|
25
|
+
export class PricingService {
|
|
26
|
+
private pricing: PricingConfig;
|
|
27
|
+
|
|
28
|
+
constructor(configPricing?: PricingConfig) {
|
|
29
|
+
this.pricing = this.mergePricing(DEFAULT_PRICING, configPricing);
|
|
30
|
+
const providerCount = Object.keys(this.pricing).length;
|
|
31
|
+
const modelCount = Object.values(this.pricing).reduce(
|
|
32
|
+
(acc, models) => acc + Object.keys(models).length,
|
|
33
|
+
0
|
|
34
|
+
);
|
|
35
|
+
logger.debug(`PricingService initialized with ${providerCount} providers, ${modelCount} models`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Deep merge pricing configs. Overrides take precedence.
|
|
40
|
+
*/
|
|
41
|
+
private mergePricing(defaults: PricingConfig, overrides?: PricingConfig): PricingConfig {
|
|
42
|
+
const result: PricingConfig = {};
|
|
43
|
+
|
|
44
|
+
// Deep copy all defaults
|
|
45
|
+
for (const [provider, models] of Object.entries(defaults)) {
|
|
46
|
+
result[provider] = {};
|
|
47
|
+
for (const [model, pricing] of Object.entries(models)) {
|
|
48
|
+
result[provider][model] = { ...pricing };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!overrides) {
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Apply overrides
|
|
57
|
+
for (const [provider, models] of Object.entries(overrides)) {
|
|
58
|
+
if (!result[provider]) {
|
|
59
|
+
result[provider] = {};
|
|
60
|
+
}
|
|
61
|
+
for (const [model, pricing] of Object.entries(models)) {
|
|
62
|
+
result[provider][model] = { ...pricing };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve provider name, checking for aliases.
|
|
71
|
+
* First checks if provider exists directly, then checks aliases.
|
|
72
|
+
*/
|
|
73
|
+
private resolveProvider(provider: string): string {
|
|
74
|
+
// Direct match takes precedence
|
|
75
|
+
if (this.pricing[provider]) {
|
|
76
|
+
return provider;
|
|
77
|
+
}
|
|
78
|
+
// Check aliases
|
|
79
|
+
return PROVIDER_ALIASES[provider] || provider;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get pricing for a specific provider and model.
|
|
84
|
+
* Returns undefined if pricing is not configured.
|
|
85
|
+
* Supports provider aliases (e.g., "gemini" -> "google").
|
|
86
|
+
*/
|
|
87
|
+
getPricing(provider: string, model: string): ModelPricing | undefined {
|
|
88
|
+
const resolvedProvider = this.resolveProvider(provider);
|
|
89
|
+
return this.pricing[resolvedProvider]?.[model];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Calculate the cost for a given number of tokens.
|
|
94
|
+
* Returns null if pricing is not configured for the provider/model.
|
|
95
|
+
*/
|
|
96
|
+
calculateCost(
|
|
97
|
+
provider: string,
|
|
98
|
+
model: string,
|
|
99
|
+
promptTokens: number,
|
|
100
|
+
completionTokens: number
|
|
101
|
+
): CostCalculation | null {
|
|
102
|
+
const pricing = this.getPricing(provider, model);
|
|
103
|
+
if (!pricing) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const inputCost = (promptTokens / 1_000_000) * pricing.inputPricePerMillion;
|
|
108
|
+
const outputCost = (completionTokens / 1_000_000) * pricing.outputPricePerMillion;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
inputCost,
|
|
112
|
+
outputCost,
|
|
113
|
+
totalCost: inputCost + outputCost,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if pricing is configured for a provider/model combination.
|
|
119
|
+
* Supports provider aliases.
|
|
120
|
+
*/
|
|
121
|
+
hasPricingFor(provider: string, model: string): boolean {
|
|
122
|
+
return this.getPricing(provider, model) !== undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get all pricing data (for debugging/display).
|
|
127
|
+
* Returns a deep copy to prevent external mutation.
|
|
128
|
+
*/
|
|
129
|
+
getAllPricing(): PricingConfig {
|
|
130
|
+
const result: PricingConfig = {};
|
|
131
|
+
for (const [provider, models] of Object.entries(this.pricing)) {
|
|
132
|
+
result[provider] = {};
|
|
133
|
+
for (const [model, pricing] of Object.entries(models)) {
|
|
134
|
+
result[provider][model] = { ...pricing };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get list of all configured providers.
|
|
142
|
+
*/
|
|
143
|
+
getProviders(): string[] {
|
|
144
|
+
return Object.keys(this.pricing);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get list of all models for a provider.
|
|
149
|
+
* Supports provider aliases.
|
|
150
|
+
*/
|
|
151
|
+
getModelsForProvider(provider: string): string[] {
|
|
152
|
+
const resolvedProvider = this.resolveProvider(provider);
|
|
153
|
+
return Object.keys(this.pricing[resolvedProvider] || {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import {
|
|
5
|
+
UsageData,
|
|
6
|
+
DailyUsage,
|
|
7
|
+
ModelUsageStats,
|
|
8
|
+
UsageTimePeriod,
|
|
9
|
+
UsageStatsResult,
|
|
10
|
+
} from '../config/types.js';
|
|
11
|
+
import { PricingService } from './pricing.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
const USAGE_DATA_VERSION = 1;
|
|
15
|
+
const DEFAULT_DEBOUNCE_MS = 5000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* UsageService tracks token usage per model per day.
|
|
19
|
+
*
|
|
20
|
+
* Data is stored in ~/.mcp-rubber-duck/data/usage.json
|
|
21
|
+
* Writes are debounced to avoid excessive disk I/O.
|
|
22
|
+
*/
|
|
23
|
+
export class UsageService {
|
|
24
|
+
private usagePath: string;
|
|
25
|
+
private data: UsageData;
|
|
26
|
+
private pricingService: PricingService;
|
|
27
|
+
private pendingWrites: number = 0;
|
|
28
|
+
private writeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
private debounceMs: number;
|
|
30
|
+
|
|
31
|
+
constructor(pricingService: PricingService, options?: { dataDir?: string; debounceMs?: number }) {
|
|
32
|
+
this.pricingService = pricingService;
|
|
33
|
+
this.debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
34
|
+
|
|
35
|
+
const dataDir = options?.dataDir ?? join(homedir(), '.mcp-rubber-duck', 'data');
|
|
36
|
+
this.ensureDirectoryExists(dataDir);
|
|
37
|
+
this.usagePath = join(dataDir, 'usage.json');
|
|
38
|
+
this.data = this.loadUsage();
|
|
39
|
+
|
|
40
|
+
logger.debug(`UsageService initialized, data path: ${this.usagePath}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private ensureDirectoryExists(dir: string): void {
|
|
44
|
+
if (!existsSync(dir)) {
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
logger.debug(`Created data directory: ${dir}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private loadUsage(): UsageData {
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(this.usagePath)) {
|
|
53
|
+
const raw = readFileSync(this.usagePath, 'utf-8');
|
|
54
|
+
const data = JSON.parse(raw) as UsageData;
|
|
55
|
+
|
|
56
|
+
// Validate structure
|
|
57
|
+
if (typeof data !== 'object' || data === null) {
|
|
58
|
+
throw new Error('Invalid usage data: not an object');
|
|
59
|
+
}
|
|
60
|
+
if (typeof data.daily !== 'object' || data.daily === null) {
|
|
61
|
+
throw new Error('Invalid usage data: missing daily object');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.debug(`Loaded usage data from ${this.usagePath}`);
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.warn('Failed to load usage data, starting fresh:', error);
|
|
69
|
+
}
|
|
70
|
+
return { version: USAGE_DATA_VERSION, daily: {} };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private saveUsage(): void {
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(this.usagePath, JSON.stringify(this.data, null, 2));
|
|
76
|
+
logger.debug(`Saved usage data to ${this.usagePath}`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.error('Failed to save usage data:', error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private scheduleSave(): void {
|
|
83
|
+
this.pendingWrites++;
|
|
84
|
+
if (this.writeDebounceTimer) {
|
|
85
|
+
clearTimeout(this.writeDebounceTimer);
|
|
86
|
+
}
|
|
87
|
+
this.writeDebounceTimer = setTimeout(() => {
|
|
88
|
+
this.saveUsage();
|
|
89
|
+
this.pendingWrites = 0;
|
|
90
|
+
this.writeDebounceTimer = null;
|
|
91
|
+
}, this.debounceMs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private getTodayKey(): string {
|
|
95
|
+
// Use local date to match getStats() behavior
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const year = now.getFullYear();
|
|
98
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
99
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
100
|
+
return `${year}-${month}-${day}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record usage for a provider/model.
|
|
105
|
+
*/
|
|
106
|
+
recordUsage(
|
|
107
|
+
provider: string,
|
|
108
|
+
model: string,
|
|
109
|
+
promptTokens: number,
|
|
110
|
+
completionTokens: number,
|
|
111
|
+
cacheHit: boolean = false,
|
|
112
|
+
error: boolean = false
|
|
113
|
+
): void {
|
|
114
|
+
const today = this.getTodayKey();
|
|
115
|
+
|
|
116
|
+
// Initialize nested structure if needed
|
|
117
|
+
if (!this.data.daily[today]) {
|
|
118
|
+
this.data.daily[today] = {};
|
|
119
|
+
}
|
|
120
|
+
if (!this.data.daily[today][provider]) {
|
|
121
|
+
this.data.daily[today][provider] = {};
|
|
122
|
+
}
|
|
123
|
+
if (!this.data.daily[today][provider][model]) {
|
|
124
|
+
this.data.daily[today][provider][model] = {
|
|
125
|
+
requests: 0,
|
|
126
|
+
promptTokens: 0,
|
|
127
|
+
completionTokens: 0,
|
|
128
|
+
cacheHits: 0,
|
|
129
|
+
errors: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const stats = this.data.daily[today][provider][model];
|
|
134
|
+
stats.requests++;
|
|
135
|
+
stats.promptTokens += promptTokens;
|
|
136
|
+
stats.completionTokens += completionTokens;
|
|
137
|
+
if (cacheHit) stats.cacheHits++;
|
|
138
|
+
if (error) stats.errors++;
|
|
139
|
+
|
|
140
|
+
logger.debug(
|
|
141
|
+
`Recorded usage: ${provider}/${model} +${promptTokens}/${completionTokens} tokens`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
this.scheduleSave();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get usage statistics for a time period.
|
|
149
|
+
*/
|
|
150
|
+
getStats(period: UsageTimePeriod): UsageStatsResult {
|
|
151
|
+
const today = new Date();
|
|
152
|
+
today.setHours(0, 0, 0, 0);
|
|
153
|
+
|
|
154
|
+
let startDate: Date;
|
|
155
|
+
switch (period) {
|
|
156
|
+
case 'today':
|
|
157
|
+
startDate = today;
|
|
158
|
+
break;
|
|
159
|
+
case '7d':
|
|
160
|
+
startDate = new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000);
|
|
161
|
+
break;
|
|
162
|
+
case '30d':
|
|
163
|
+
startDate = new Date(today.getTime() - 29 * 24 * 60 * 60 * 1000);
|
|
164
|
+
break;
|
|
165
|
+
case 'all':
|
|
166
|
+
startDate = new Date(0);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const aggregated: DailyUsage = {};
|
|
171
|
+
const totals: ModelUsageStats = {
|
|
172
|
+
requests: 0,
|
|
173
|
+
promptTokens: 0,
|
|
174
|
+
completionTokens: 0,
|
|
175
|
+
cacheHits: 0,
|
|
176
|
+
errors: 0,
|
|
177
|
+
};
|
|
178
|
+
const costByProvider: Record<string, number> = {};
|
|
179
|
+
let totalCost = 0;
|
|
180
|
+
let hasCostData = false;
|
|
181
|
+
|
|
182
|
+
for (const [dateKey, dayData] of Object.entries(this.data.daily)) {
|
|
183
|
+
// Parse dateKey as local date (not UTC) to match getTodayKey() format
|
|
184
|
+
// dateKey is "YYYY-MM-DD", we need to parse it as local midnight
|
|
185
|
+
const [year, month, day] = dateKey.split('-').map(Number);
|
|
186
|
+
const date = new Date(year, month - 1, day); // month is 0-indexed
|
|
187
|
+
|
|
188
|
+
// Skip invalid dates or dates outside range
|
|
189
|
+
if (isNaN(date.getTime()) || date < startDate || date > today) continue;
|
|
190
|
+
|
|
191
|
+
for (const [provider, providerData] of Object.entries(dayData)) {
|
|
192
|
+
if (!aggregated[provider]) {
|
|
193
|
+
aggregated[provider] = {};
|
|
194
|
+
costByProvider[provider] = 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [model, stats] of Object.entries(providerData)) {
|
|
198
|
+
if (!aggregated[provider][model]) {
|
|
199
|
+
aggregated[provider][model] = {
|
|
200
|
+
requests: 0,
|
|
201
|
+
promptTokens: 0,
|
|
202
|
+
completionTokens: 0,
|
|
203
|
+
cacheHits: 0,
|
|
204
|
+
errors: 0,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const agg = aggregated[provider][model];
|
|
209
|
+
agg.requests += stats.requests;
|
|
210
|
+
agg.promptTokens += stats.promptTokens;
|
|
211
|
+
agg.completionTokens += stats.completionTokens;
|
|
212
|
+
agg.cacheHits += stats.cacheHits;
|
|
213
|
+
agg.errors += stats.errors;
|
|
214
|
+
|
|
215
|
+
totals.requests += stats.requests;
|
|
216
|
+
totals.promptTokens += stats.promptTokens;
|
|
217
|
+
totals.completionTokens += stats.completionTokens;
|
|
218
|
+
totals.cacheHits += stats.cacheHits;
|
|
219
|
+
totals.errors += stats.errors;
|
|
220
|
+
|
|
221
|
+
// Calculate cost if pricing available
|
|
222
|
+
const cost = this.pricingService.calculateCost(
|
|
223
|
+
provider,
|
|
224
|
+
model,
|
|
225
|
+
stats.promptTokens,
|
|
226
|
+
stats.completionTokens
|
|
227
|
+
);
|
|
228
|
+
if (cost) {
|
|
229
|
+
totalCost += cost.totalCost;
|
|
230
|
+
costByProvider[provider] += cost.totalCost;
|
|
231
|
+
hasCostData = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result: UsageStatsResult = {
|
|
238
|
+
period,
|
|
239
|
+
startDate: this.formatDate(startDate),
|
|
240
|
+
endDate: this.formatDate(today),
|
|
241
|
+
usage: aggregated,
|
|
242
|
+
totals: {
|
|
243
|
+
...totals,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (hasCostData) {
|
|
248
|
+
result.totals.estimatedCostUSD = totalCost;
|
|
249
|
+
result.costByProvider = costByProvider;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private formatDate(date: Date): string {
|
|
256
|
+
// Use local date components to match getTodayKey() and date filtering
|
|
257
|
+
const year = date.getFullYear();
|
|
258
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
259
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
260
|
+
return `${year}-${month}-${day}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Flush pending writes immediately. Call on shutdown.
|
|
265
|
+
*/
|
|
266
|
+
shutdown(): void {
|
|
267
|
+
if (this.writeDebounceTimer) {
|
|
268
|
+
clearTimeout(this.writeDebounceTimer);
|
|
269
|
+
this.writeDebounceTimer = null;
|
|
270
|
+
}
|
|
271
|
+
if (this.pendingWrites > 0) {
|
|
272
|
+
this.saveUsage();
|
|
273
|
+
this.pendingWrites = 0;
|
|
274
|
+
}
|
|
275
|
+
logger.debug('UsageService shutdown complete');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get raw usage data (for testing/debugging).
|
|
280
|
+
* Returns a deep copy to prevent external mutation.
|
|
281
|
+
*/
|
|
282
|
+
getRawData(): UsageData {
|
|
283
|
+
return JSON.parse(JSON.stringify(this.data)) as UsageData;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Clear all usage data (for testing).
|
|
288
|
+
*/
|
|
289
|
+
clearData(): void {
|
|
290
|
+
this.data = { version: USAGE_DATA_VERSION, daily: {} };
|
|
291
|
+
this.scheduleSave();
|
|
292
|
+
}
|
|
293
|
+
}
|