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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-smartmeter",
3
- "version": "0.2.2",
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": ["openclaw", "ai-cost-optimization", "token-monitoring", "llm-costs", "cost-reduction"],
22
- "author": "",
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
+ }
@@ -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 - Current optimization status
314
- GET /api/preview - Preview config changes
315
- POST /api/apply - Apply optimizations
316
- GET /api/evaluate - Evaluate configuration
317
- GET /api/export - Export analysis report
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
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -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}/__openclaw__/canvas/smartmeter/`;
166
+ return `http://localhost:${port}`;
167
167
  }
168
168
 
169
169
  /**
@@ -143,8 +143,9 @@ Opening in your browser...
143
143
  process.on("SIGINT", shutdownHandler);
144
144
  process.on("SIGTERM", shutdownHandler);
145
145
 
146
- // Store server references for cleanup
147
- analysis._servers = { staticServer, apiServer };
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 dailyBudget = Math.ceil(dailyAvg * 1.2 * 100) / 100;
73
- const weeklyBudget = Math.ceil(dailyBudget * 7 * 100) / 100;
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 ALLOWED_TOP_KEYS = new Set([
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
- // Check top-level keys
21
- for (const key of Object.keys(config)) {
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;