openclaw-smartmeter 0.2.2 ā 0.3.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/CHANGELOG.md +201 -0
- package/DEPLOYMENT_HANDOFF.md +923 -0
- package/HANDOFF_BRIEF.md +619 -0
- package/README.md +64 -0
- package/SKILL.md +654 -0
- package/canvas-template/app.js +273 -11
- package/canvas-template/index.html +49 -0
- package/canvas-template/styles.css +764 -90
- package/package.json +19 -3
- package/src/analyzer/config-manager.js +92 -0
- package/src/analyzer/openrouter-client.js +112 -0
- package/src/canvas/api-server.js +104 -5
- package/src/canvas/deployer.js +1 -1
- package/src/cli/commands.js +3 -2
- package/src/generator/config-builder.js +7 -2
- package/src/generator/validator.js +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-smartmeter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AI cost optimization for OpenClaw - analyze usage and reduce costs by 48%",
|
|
5
5
|
"main": "src/cli/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -18,8 +18,24 @@
|
|
|
18
18
|
"dashboard": "node src/cli/index.js dashboard",
|
|
19
19
|
"dashboard:preview": "python3 canvas-template/preview-server.py"
|
|
20
20
|
},
|
|
21
|
-
"keywords": [
|
|
22
|
-
|
|
21
|
+
"keywords": [
|
|
22
|
+
"openclaw",
|
|
23
|
+
"openclaw-skill",
|
|
24
|
+
"ai-cost-optimization",
|
|
25
|
+
"token-monitoring",
|
|
26
|
+
"llm-costs",
|
|
27
|
+
"cost-reduction",
|
|
28
|
+
"budget-management",
|
|
29
|
+
"config-optimization"
|
|
30
|
+
],
|
|
31
|
+
"openclaw": {
|
|
32
|
+
"skill": true,
|
|
33
|
+
"name": "smartmeter",
|
|
34
|
+
"category": "productivity",
|
|
35
|
+
"emoji": "š°",
|
|
36
|
+
"manifest": "SKILL.md"
|
|
37
|
+
},
|
|
38
|
+
"author": "vajihkhan <vajihkhan@gmail.com>",
|
|
23
39
|
"license": "Apache-2.0",
|
|
24
40
|
"type": "module",
|
|
25
41
|
"repository": {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Manager
|
|
3
|
+
* Handles secure storage of OpenRouter API key and other config
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = join(homedir(), '.openclaw', 'smartmeter');
|
|
12
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get configuration including OpenRouter API key
|
|
16
|
+
* @returns {Promise<Object>} Configuration object
|
|
17
|
+
*/
|
|
18
|
+
export async function getConfig() {
|
|
19
|
+
try {
|
|
20
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
21
|
+
return getDefaultConfig();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const content = await readFile(CONFIG_FILE, 'utf-8');
|
|
25
|
+
const config = JSON.parse(content);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...getDefaultConfig(),
|
|
29
|
+
...config
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.warn('Failed to read config, using defaults:', error.message);
|
|
33
|
+
return getDefaultConfig();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save configuration
|
|
39
|
+
* @param {Object} config - Configuration object to save
|
|
40
|
+
*/
|
|
41
|
+
export async function saveConfig(config) {
|
|
42
|
+
try {
|
|
43
|
+
// Ensure directory exists
|
|
44
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
45
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Failed to save config: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set OpenRouter API key
|
|
56
|
+
* @param {string} apiKey - OpenRouter API key
|
|
57
|
+
*/
|
|
58
|
+
export async function setOpenRouterApiKey(apiKey) {
|
|
59
|
+
const config = await getConfig();
|
|
60
|
+
config.openRouterApiKey = apiKey;
|
|
61
|
+
await saveConfig(config);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get OpenRouter API key
|
|
66
|
+
* @returns {Promise<string|null>} API key or null if not set
|
|
67
|
+
*/
|
|
68
|
+
export async function getOpenRouterApiKey() {
|
|
69
|
+
const config = await getConfig();
|
|
70
|
+
return config.openRouterApiKey || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove OpenRouter API key
|
|
75
|
+
*/
|
|
76
|
+
export async function removeOpenRouterApiKey() {
|
|
77
|
+
const config = await getConfig();
|
|
78
|
+
delete config.openRouterApiKey;
|
|
79
|
+
await saveConfig(config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get default configuration
|
|
84
|
+
* @returns {Object} Default config
|
|
85
|
+
*/
|
|
86
|
+
function getDefaultConfig() {
|
|
87
|
+
return {
|
|
88
|
+
openRouterApiKey: null,
|
|
89
|
+
enableOpenRouterIntegration: true,
|
|
90
|
+
lastUpdated: null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter API Client
|
|
3
|
+
* Fetches live usage data from OpenRouter to compare with analyzed session data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import https from 'https';
|
|
7
|
+
|
|
8
|
+
const OPENROUTER_API_BASE = 'https://openrouter.ai/api/v1';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fetch account credits and usage from OpenRouter
|
|
12
|
+
* @param {string} apiKey - OpenRouter API key
|
|
13
|
+
* @returns {Promise<Object>} Usage data including credits, generations, and costs
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchOpenRouterUsage(apiKey) {
|
|
16
|
+
if (!apiKey || !apiKey.startsWith('sk-or-')) {
|
|
17
|
+
throw new Error('Invalid OpenRouter API key format (should start with "sk-or-")');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Fetch credits/balance
|
|
22
|
+
const creditsData = await makeRequest('/auth/key', apiKey);
|
|
23
|
+
|
|
24
|
+
// Calculate usage based on available data
|
|
25
|
+
const usage = {
|
|
26
|
+
success: true,
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
account: {
|
|
29
|
+
label: creditsData.data?.label || 'Unknown',
|
|
30
|
+
limit: creditsData.data?.limit || null,
|
|
31
|
+
usageBalance: creditsData.data?.usage || null,
|
|
32
|
+
limitRemaining: creditsData.data?.limit_remaining || null,
|
|
33
|
+
isFreeTier: creditsData.data?.is_free_tier || false,
|
|
34
|
+
},
|
|
35
|
+
// OpenRouter returns usage in USD cents typically
|
|
36
|
+
totalSpent: creditsData.data?.usage ? (creditsData.data.usage / 100) : null,
|
|
37
|
+
note: 'OpenRouter API integration active'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return usage;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.statusCode === 401) {
|
|
43
|
+
throw new Error('Invalid OpenRouter API key - authentication failed');
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`OpenRouter API error: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Make HTTPS request to OpenRouter API
|
|
51
|
+
* @param {string} endpoint - API endpoint path
|
|
52
|
+
* @param {string} apiKey - OpenRouter API key
|
|
53
|
+
* @returns {Promise<Object>} Response data
|
|
54
|
+
*/
|
|
55
|
+
function makeRequest(endpoint, apiKey) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const options = {
|
|
58
|
+
hostname: 'openrouter.ai',
|
|
59
|
+
path: `/api/v1${endpoint}`,
|
|
60
|
+
method: 'GET',
|
|
61
|
+
headers: {
|
|
62
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
63
|
+
'Content-Type': 'application/json'
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const req = https.request(options, (res) => {
|
|
68
|
+
let data = '';
|
|
69
|
+
|
|
70
|
+
res.on('data', (chunk) => {
|
|
71
|
+
data += chunk;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
res.on('end', () => {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(data);
|
|
77
|
+
|
|
78
|
+
if (res.statusCode === 200) {
|
|
79
|
+
resolve(parsed);
|
|
80
|
+
} else {
|
|
81
|
+
const error = new Error(parsed.error?.message || 'OpenRouter API error');
|
|
82
|
+
error.statusCode = res.statusCode;
|
|
83
|
+
error.response = parsed;
|
|
84
|
+
reject(error);
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
reject(new Error(`Failed to parse OpenRouter response: ${e.message}`));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
req.on('error', (error) => {
|
|
93
|
+
reject(new Error(`Network error: ${error.message}`));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
req.setTimeout(10000, () => {
|
|
97
|
+
req.destroy();
|
|
98
|
+
reject(new Error('Request timeout'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
req.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate OpenRouter API key format
|
|
107
|
+
* @param {string} apiKey - API key to validate
|
|
108
|
+
* @returns {boolean} True if format is valid
|
|
109
|
+
*/
|
|
110
|
+
export function isValidApiKeyFormat(apiKey) {
|
|
111
|
+
return typeof apiKey === 'string' && apiKey.startsWith('sk-or-') && apiKey.length > 20;
|
|
112
|
+
}
|
package/src/canvas/api-server.js
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
cmdStatus,
|
|
10
10
|
} from "../cli/commands.js";
|
|
11
11
|
|
|
12
|
+
import { fetchOpenRouterUsage, isValidApiKeyFormat } from "../analyzer/openrouter-client.js";
|
|
13
|
+
import { getOpenRouterApiKey, setOpenRouterApiKey, getConfig } from "../analyzer/config-manager.js";
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Simple API server for SmartMeter dashboard.
|
|
14
17
|
* Provides REST endpoints for the web UI to interact with CLI functions.
|
|
@@ -84,6 +87,12 @@ export class ApiServer {
|
|
|
84
87
|
await this.handleEvaluate(req, res);
|
|
85
88
|
} else if (path === "/api/export" && req.method === "GET") {
|
|
86
89
|
await this.handleExport(req, res);
|
|
90
|
+
} else if (path === "/api/openrouter-usage" && req.method === "GET") {
|
|
91
|
+
await this.handleOpenRouterUsage(req, res);
|
|
92
|
+
} else if (path === "/api/config/openrouter-key" && req.method === "POST") {
|
|
93
|
+
await this.handleSetOpenRouterKey(req, res);
|
|
94
|
+
} else if (path === "/api/config/openrouter-key" && req.method === "GET") {
|
|
95
|
+
await this.handleGetOpenRouterKeyStatus(req, res);
|
|
87
96
|
} else {
|
|
88
97
|
this.sendError(res, 404, "Not found");
|
|
89
98
|
}
|
|
@@ -255,6 +264,93 @@ ${analysis.recommendations.map((rec, i) => `${i + 1}. **${rec.title}**\n ${rec
|
|
|
255
264
|
`;
|
|
256
265
|
}
|
|
257
266
|
|
|
267
|
+
/**
|
|
268
|
+
* GET /api/openrouter-usage - Fetch actual OpenRouter usage
|
|
269
|
+
*/
|
|
270
|
+
async handleOpenRouterUsage(req, res) {
|
|
271
|
+
try {
|
|
272
|
+
const apiKey = await getOpenRouterApiKey();
|
|
273
|
+
|
|
274
|
+
if (!apiKey) {
|
|
275
|
+
this.sendJson(res, {
|
|
276
|
+
success: false,
|
|
277
|
+
configured: false,
|
|
278
|
+
message: "OpenRouter API key not configured"
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const usage = await fetchOpenRouterUsage(apiKey);
|
|
284
|
+
this.sendJson(res, {
|
|
285
|
+
success: true,
|
|
286
|
+
configured: true,
|
|
287
|
+
...usage
|
|
288
|
+
});
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.sendJson(res, {
|
|
291
|
+
success: false,
|
|
292
|
+
configured: true,
|
|
293
|
+
error: error.message
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* POST /api/config/openrouter-key - Set OpenRouter API key
|
|
300
|
+
*/
|
|
301
|
+
async handleSetOpenRouterKey(req, res) {
|
|
302
|
+
try {
|
|
303
|
+
const body = await this.parseBody(req);
|
|
304
|
+
const apiKey = body?.apiKey;
|
|
305
|
+
|
|
306
|
+
if (!apiKey) {
|
|
307
|
+
this.sendError(res, 400, "API key required");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!isValidApiKeyFormat(apiKey)) {
|
|
312
|
+
this.sendError(res, 400, "Invalid API key format (should start with 'sk-or-')");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Test the key before saving
|
|
317
|
+
try {
|
|
318
|
+
await fetchOpenRouterUsage(apiKey);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.sendError(res, 401, `API key validation failed: ${error.message}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await setOpenRouterApiKey(apiKey);
|
|
325
|
+
|
|
326
|
+
this.sendJson(res, {
|
|
327
|
+
success: true,
|
|
328
|
+
message: "OpenRouter API key saved and validated"
|
|
329
|
+
});
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.sendError(res, 500, error.message);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* GET /api/config/openrouter-key - Check if OpenRouter key is configured
|
|
337
|
+
*/
|
|
338
|
+
async handleGetOpenRouterKeyStatus(req, res) {
|
|
339
|
+
try {
|
|
340
|
+
const apiKey = await getOpenRouterApiKey();
|
|
341
|
+
const config = await getConfig();
|
|
342
|
+
|
|
343
|
+
this.sendJson(res, {
|
|
344
|
+
success: true,
|
|
345
|
+
configured: !!apiKey,
|
|
346
|
+
keyPreview: apiKey ? `${apiKey.substring(0, 9)}...${apiKey.substring(apiKey.length - 4)}` : null,
|
|
347
|
+
enabledIntegration: config.enableOpenRouterIntegration !== false
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.sendError(res, 500, error.message);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
258
354
|
/**
|
|
259
355
|
* Parse JSON body from request
|
|
260
356
|
*/
|
|
@@ -310,11 +406,14 @@ export async function startApiServer(opts = {}) {
|
|
|
310
406
|
š API Server: http://localhost:${port}
|
|
311
407
|
|
|
312
408
|
š” Available Endpoints:
|
|
313
|
-
GET /api/status
|
|
314
|
-
GET /api/preview
|
|
315
|
-
POST /api/apply
|
|
316
|
-
GET /api/evaluate
|
|
317
|
-
GET /api/export
|
|
409
|
+
GET /api/status - Current optimization status
|
|
410
|
+
GET /api/preview - Preview config changes
|
|
411
|
+
POST /api/apply - Apply optimizations
|
|
412
|
+
GET /api/evaluate - Evaluate configuration
|
|
413
|
+
GET /api/export - Export analysis report
|
|
414
|
+
GET /api/openrouter-usage - Fetch actual OpenRouter usage
|
|
415
|
+
POST /api/config/openrouter-key - Set OpenRouter API key
|
|
416
|
+
GET /api/config/openrouter-key - Check API key status
|
|
318
417
|
|
|
319
418
|
Press Ctrl+C to stop
|
|
320
419
|
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
package/src/canvas/deployer.js
CHANGED
|
@@ -163,7 +163,7 @@ export class CanvasDeployer {
|
|
|
163
163
|
* Return the canvas URL for the given gateway port.
|
|
164
164
|
*/
|
|
165
165
|
getCanvasUrl(port = 8080) {
|
|
166
|
-
return `http://localhost:${port}
|
|
166
|
+
return `http://localhost:${port}`;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
package/src/cli/commands.js
CHANGED
|
@@ -143,8 +143,9 @@ Opening in your browser...
|
|
|
143
143
|
process.on("SIGINT", shutdownHandler);
|
|
144
144
|
process.on("SIGTERM", shutdownHandler);
|
|
145
145
|
|
|
146
|
-
//
|
|
147
|
-
|
|
146
|
+
// Keep process alive - wait indefinitely
|
|
147
|
+
// This prevents the CLI from exiting and killing the servers
|
|
148
|
+
await new Promise(() => {}); // Never resolves, keeps process alive
|
|
148
149
|
|
|
149
150
|
} catch (err) {
|
|
150
151
|
console.error(`\nā Could not start dashboard: ${err.message}`);
|
|
@@ -68,9 +68,14 @@ export function generateConfig(analysis, currentConfig = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// 5. Budget controls
|
|
71
|
+
// Use minimum budget values when costs are zero or very low to ensure valid config
|
|
72
|
+
const MIN_DAILY_BUDGET = 1.00; // $1/day minimum
|
|
73
|
+
const MIN_WEEKLY_BUDGET = 5.00; // $5/week minimum
|
|
74
|
+
|
|
71
75
|
const dailyAvg = (analysis.summary.currentMonthlyCost || 0) / 30;
|
|
72
|
-
const
|
|
73
|
-
const
|
|
76
|
+
const calculatedDaily = Math.ceil(dailyAvg * 1.2 * 100) / 100;
|
|
77
|
+
const dailyBudget = Math.max(calculatedDaily, MIN_DAILY_BUDGET);
|
|
78
|
+
const weeklyBudget = Math.max(Math.ceil(dailyBudget * 7 * 100) / 100, MIN_WEEKLY_BUDGET);
|
|
74
79
|
|
|
75
80
|
config.agents.defaults.budget = deepMerge(
|
|
76
81
|
config.agents.defaults.budget || {},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
const SMARTMETER_KEYS = new Set([
|
|
2
2
|
"agents",
|
|
3
3
|
"skills",
|
|
4
4
|
"models",
|
|
@@ -9,6 +9,10 @@ const ALLOWED_TOP_KEYS = new Set([
|
|
|
9
9
|
/**
|
|
10
10
|
* Validate an openclaw config object.
|
|
11
11
|
* Returns { valid: boolean, errors: string[] }.
|
|
12
|
+
*
|
|
13
|
+
* Note: This validator focuses on SmartMeter-managed fields only.
|
|
14
|
+
* It allows other OpenClaw config keys (meta, wizard, auth, tools, etc.)
|
|
15
|
+
* to pass through without validation.
|
|
12
16
|
*/
|
|
13
17
|
export function validate(config) {
|
|
14
18
|
const errors = [];
|
|
@@ -17,12 +21,8 @@ export function validate(config) {
|
|
|
17
21
|
return { valid: false, errors: ["Config must be a non-null object"] };
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
if (!ALLOWED_TOP_KEYS.has(key)) {
|
|
23
|
-
errors.push(`Unknown top-level key: "${key}"`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
24
|
+
// Only validate SmartMeter-managed keys, not the entire OpenClaw config
|
|
25
|
+
// This allows existing OpenClaw configs to merge without validation errors
|
|
26
26
|
|
|
27
27
|
// agents.defaults.model.primary must exist and be a string
|
|
28
28
|
const primary = config.agents?.defaults?.model?.primary;
|