@chrysb/alphaclaw 0.4.3 → 0.4.5
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/lib/public/js/components/file-tree.js +37 -8
- package/lib/public/js/components/gateway.js +74 -42
- package/lib/public/js/components/icons.js +13 -0
- package/lib/public/js/components/usage-tab/overview-section.js +100 -26
- package/lib/public/js/lib/api.js +31 -0
- package/lib/server/constants.js +22 -26
- package/lib/server/db/usage/index.js +35 -0
- package/lib/server/db/usage/pricing.js +82 -0
- package/lib/server/db/usage/schema.js +87 -0
- package/lib/server/db/usage/sessions.js +217 -0
- package/lib/server/db/usage/shared.js +139 -0
- package/lib/server/db/usage/summary.js +280 -0
- package/lib/server/db/usage/timeseries.js +64 -0
- package/lib/server/{watchdog-db.js → db/watchdog/index.js} +1 -18
- package/lib/server/db/watchdog/schema.js +21 -0
- package/lib/server/{webhooks-db.js → db/webhooks/index.js} +1 -22
- package/lib/server/db/webhooks/schema.js +25 -0
- package/lib/server/gmail-push.js +102 -6
- package/lib/server/gmail-watch.js +5 -20
- package/lib/server/helpers.js +5 -21
- package/lib/server/routes/browse/index.js +29 -0
- package/lib/server/routes/google.js +2 -10
- package/lib/server/routes/telegram.js +3 -14
- package/lib/server/routes/usage.js +1 -5
- package/lib/server/routes/webhooks.js +2 -6
- package/lib/server/utils/boolean.js +22 -0
- package/lib/server/utils/json.js +31 -0
- package/lib/server/utils/network.js +5 -0
- package/lib/server/utils/number.js +8 -0
- package/lib/server/utils/shell.js +16 -0
- package/lib/server/webhook-middleware.js +1 -2
- package/lib/server.js +3 -3
- package/package.json +1 -1
- package/lib/server/usage-db.js +0 -838
package/lib/server/constants.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
const os = require("os");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const kBrowseFilePolicies = require("../public/shared/browse-file-policies.json");
|
|
4
|
-
|
|
5
|
-
const parsePositiveIntEnv = (value, fallbackValue) => {
|
|
6
|
-
const parsed = Number.parseInt(String(value || ""), 10);
|
|
7
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackValue;
|
|
8
|
-
};
|
|
4
|
+
const { parsePositiveInt } = require("./utils/number");
|
|
9
5
|
|
|
10
6
|
// Portable root directory: --root-dir flag sets ALPHACLAW_ROOT_DIR before require
|
|
11
7
|
const kRootDir =
|
|
@@ -38,29 +34,29 @@ const CODEX_OAUTH_SCOPE = "openid profile email offline_access";
|
|
|
38
34
|
const CODEX_JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
39
35
|
const kCodexOauthStateTtlMs = 10 * 60 * 1000;
|
|
40
36
|
|
|
41
|
-
const kTrustProxyHops =
|
|
42
|
-
const kLoginWindowMs =
|
|
37
|
+
const kTrustProxyHops = parsePositiveInt(process.env.TRUST_PROXY_HOPS, 1);
|
|
38
|
+
const kLoginWindowMs = parsePositiveInt(
|
|
43
39
|
process.env.LOGIN_RATE_WINDOW_MS,
|
|
44
40
|
10 * 60 * 1000,
|
|
45
41
|
);
|
|
46
|
-
const kLoginMaxAttempts =
|
|
42
|
+
const kLoginMaxAttempts = parsePositiveInt(
|
|
47
43
|
process.env.LOGIN_RATE_MAX_ATTEMPTS,
|
|
48
44
|
5,
|
|
49
45
|
);
|
|
50
|
-
const kLoginBaseLockMs =
|
|
46
|
+
const kLoginBaseLockMs = parsePositiveInt(
|
|
51
47
|
process.env.LOGIN_RATE_BASE_LOCK_MS,
|
|
52
48
|
60 * 1000,
|
|
53
49
|
);
|
|
54
|
-
const kLoginMaxLockMs =
|
|
50
|
+
const kLoginMaxLockMs = parsePositiveInt(
|
|
55
51
|
process.env.LOGIN_RATE_MAX_LOCK_MS,
|
|
56
52
|
15 * 60 * 1000,
|
|
57
53
|
);
|
|
58
|
-
const kLoginCleanupIntervalMs =
|
|
54
|
+
const kLoginCleanupIntervalMs = parsePositiveInt(
|
|
59
55
|
process.env.LOGIN_RATE_CLEANUP_INTERVAL_MS,
|
|
60
56
|
60 * 1000,
|
|
61
57
|
);
|
|
62
58
|
const kLoginStateTtlMs = Math.max(
|
|
63
|
-
|
|
59
|
+
parsePositiveInt(
|
|
64
60
|
process.env.LOGIN_RATE_STATE_TTL_MS,
|
|
65
61
|
Math.max(kLoginWindowMs, kLoginMaxLockMs) * 3,
|
|
66
62
|
),
|
|
@@ -116,31 +112,31 @@ const kLatestVersionCacheTtlMs = 10 * 60 * 1000;
|
|
|
116
112
|
const kOpenclawRegistryUrl = "https://registry.npmjs.org/openclaw";
|
|
117
113
|
const kAlphaclawRegistryUrl = "https://registry.npmjs.org/@chrysb%2falphaclaw";
|
|
118
114
|
const kAppDir = kNpmPackageRoot;
|
|
119
|
-
const kMaxPayloadBytes =
|
|
120
|
-
const kWebhookPruneDays =
|
|
115
|
+
const kMaxPayloadBytes = parsePositiveInt(process.env.WEBHOOK_LOG_MAX_BYTES, 50 * 1024);
|
|
116
|
+
const kWebhookPruneDays = parsePositiveInt(process.env.WEBHOOK_LOG_RETENTION_DAYS, 30);
|
|
121
117
|
const kWatchdogCheckIntervalMs =
|
|
122
|
-
|
|
118
|
+
parsePositiveInt(process.env.WATCHDOG_CHECK_INTERVAL, 120) * 1000;
|
|
123
119
|
const kWatchdogDegradedCheckIntervalMs =
|
|
124
|
-
|
|
125
|
-
const kWatchdogStartupFailureThreshold =
|
|
120
|
+
parsePositiveInt(process.env.WATCHDOG_DEGRADED_CHECK_INTERVAL, 5) * 1000;
|
|
121
|
+
const kWatchdogStartupFailureThreshold = parsePositiveInt(
|
|
126
122
|
process.env.WATCHDOG_STARTUP_FAILURE_THRESHOLD,
|
|
127
123
|
3,
|
|
128
124
|
);
|
|
129
|
-
const kWatchdogMaxRepairAttempts =
|
|
125
|
+
const kWatchdogMaxRepairAttempts = parsePositiveInt(
|
|
130
126
|
process.env.WATCHDOG_MAX_REPAIR_ATTEMPTS,
|
|
131
127
|
2,
|
|
132
128
|
);
|
|
133
129
|
const kWatchdogCrashLoopWindowMs =
|
|
134
|
-
|
|
135
|
-
const kWatchdogCrashLoopThreshold =
|
|
130
|
+
parsePositiveInt(process.env.WATCHDOG_CRASH_LOOP_WINDOW, 300) * 1000;
|
|
131
|
+
const kWatchdogCrashLoopThreshold = parsePositiveInt(
|
|
136
132
|
process.env.WATCHDOG_CRASH_LOOP_THRESHOLD,
|
|
137
133
|
3,
|
|
138
134
|
);
|
|
139
|
-
const kWatchdogLogRetentionDays =
|
|
135
|
+
const kWatchdogLogRetentionDays = parsePositiveInt(
|
|
140
136
|
process.env.WATCHDOG_LOG_RETENTION_DAYS,
|
|
141
137
|
30,
|
|
142
138
|
);
|
|
143
|
-
const kLogMaxBytes =
|
|
139
|
+
const kLogMaxBytes = parsePositiveInt(
|
|
144
140
|
process.env.LOG_MAX_BYTES,
|
|
145
141
|
2 * 1024 * 1024,
|
|
146
142
|
);
|
|
@@ -266,17 +262,17 @@ const GOG_CREDENTIALS_PATH = path.join(GOG_CONFIG_DIR, "credentials.json");
|
|
|
266
262
|
const GOG_STATE_PATH = path.join(GOG_CONFIG_DIR, "state.json");
|
|
267
263
|
const GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || "alphaclaw";
|
|
268
264
|
const kMaxGoogleAccounts = 5;
|
|
269
|
-
const kGmailServeBasePort =
|
|
265
|
+
const kGmailServeBasePort = parsePositiveInt(
|
|
270
266
|
process.env.GMAIL_SERVE_BASE_PORT,
|
|
271
267
|
18801,
|
|
272
268
|
);
|
|
273
269
|
const kGmailWatchRenewalIntervalMs =
|
|
274
|
-
|
|
270
|
+
parsePositiveInt(process.env.GMAIL_WATCH_RENEWAL_INTERVAL_SECONDS, 6 * 60 * 60) *
|
|
275
271
|
1000;
|
|
276
272
|
const kGmailWatchRenewalThresholdMs =
|
|
277
|
-
|
|
273
|
+
parsePositiveInt(process.env.GMAIL_WATCH_RENEWAL_THRESHOLD_SECONDS, 24 * 60 * 60) *
|
|
278
274
|
1000;
|
|
279
|
-
const kGmailMaxBodyBytes =
|
|
275
|
+
const kGmailMaxBodyBytes = parsePositiveInt(
|
|
280
276
|
process.env.GMAIL_WATCH_MAX_BODY_BYTES,
|
|
281
277
|
20000,
|
|
282
278
|
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
4
|
+
const { ensureSchema } = require("./schema");
|
|
5
|
+
const { getDailySummary } = require("./summary");
|
|
6
|
+
const { getSessionsList, getSessionDetail } = require("./sessions");
|
|
7
|
+
const { getSessionTimeSeries } = require("./timeseries");
|
|
8
|
+
const { kGlobalModelPricing } = require("./pricing");
|
|
9
|
+
|
|
10
|
+
let db = null;
|
|
11
|
+
let usageDbPath = "";
|
|
12
|
+
|
|
13
|
+
const ensureDb = () => {
|
|
14
|
+
if (!db) throw new Error("Usage DB not initialized");
|
|
15
|
+
return db;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const initUsageDb = ({ rootDir }) => {
|
|
19
|
+
const dbDir = path.join(rootDir, "db");
|
|
20
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
21
|
+
usageDbPath = path.join(dbDir, "usage.db");
|
|
22
|
+
db = new DatabaseSync(usageDbPath);
|
|
23
|
+
ensureSchema(db);
|
|
24
|
+
return { path: usageDbPath };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
initUsageDb,
|
|
29
|
+
getDailySummary: (options = {}) => getDailySummary({ database: ensureDb(), ...options }),
|
|
30
|
+
getSessionsList: (options = {}) => getSessionsList({ database: ensureDb(), ...options }),
|
|
31
|
+
getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }),
|
|
32
|
+
getSessionTimeSeries: (options = {}) =>
|
|
33
|
+
getSessionTimeSeries({ database: ensureDb(), ...options }),
|
|
34
|
+
kGlobalModelPricing,
|
|
35
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const kTokensPerMillion = 1_000_000;
|
|
2
|
+
const kLongContextThresholdTokens = 200_000;
|
|
3
|
+
const kGlobalModelPricing = {
|
|
4
|
+
"claude-opus-4-6": {
|
|
5
|
+
input: (tokens) => (tokens > kLongContextThresholdTokens ? 10.0 : 5.0),
|
|
6
|
+
output: (tokens) => (tokens > kLongContextThresholdTokens ? 37.5 : 25.0),
|
|
7
|
+
},
|
|
8
|
+
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
9
|
+
"claude-haiku-4-6": { input: 0.8, output: 4.0 },
|
|
10
|
+
"gpt-5": { input: 1.25, output: 10.0 },
|
|
11
|
+
"gpt-5.1-codex": { input: 2.5, output: 10.0 },
|
|
12
|
+
"gpt-5.3-codex": { input: 2.5, output: 10.0 },
|
|
13
|
+
"gpt-4.1": { input: 2.0, output: 8.0 },
|
|
14
|
+
"gpt-4o": { input: 2.5, output: 10.0 },
|
|
15
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
16
|
+
"gemini-3-pro-preview": { input: 2.0, output: 12.0 },
|
|
17
|
+
"gemini-3-flash-preview": { input: 0.5, output: 3.0 },
|
|
18
|
+
"gemini-2.0-flash": { input: 0.1, output: 0.4 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const toInt = (value, fallbackValue = 0) => {
|
|
22
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
23
|
+
return Number.isFinite(parsed) ? parsed : fallbackValue;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const resolvePricing = (model) => {
|
|
27
|
+
const normalized = String(model || "").toLowerCase();
|
|
28
|
+
if (!normalized) return null;
|
|
29
|
+
const exact = kGlobalModelPricing[normalized];
|
|
30
|
+
if (exact) return exact;
|
|
31
|
+
const matchKey = Object.keys(kGlobalModelPricing).find((key) =>
|
|
32
|
+
normalized.includes(key),
|
|
33
|
+
);
|
|
34
|
+
return matchKey ? kGlobalModelPricing[matchKey] : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const resolvePerMillionRate = (rate, tokens) => {
|
|
38
|
+
if (typeof rate === "function") {
|
|
39
|
+
return Number(rate(toInt(tokens)));
|
|
40
|
+
}
|
|
41
|
+
return Number(rate || 0);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const deriveCostBreakdown = ({
|
|
45
|
+
inputTokens = 0,
|
|
46
|
+
outputTokens = 0,
|
|
47
|
+
cacheReadTokens = 0,
|
|
48
|
+
cacheWriteTokens = 0,
|
|
49
|
+
model = "",
|
|
50
|
+
}) => {
|
|
51
|
+
const pricing = resolvePricing(model);
|
|
52
|
+
if (!pricing) {
|
|
53
|
+
return {
|
|
54
|
+
inputCost: 0,
|
|
55
|
+
outputCost: 0,
|
|
56
|
+
cacheReadCost: 0,
|
|
57
|
+
cacheWriteCost: 0,
|
|
58
|
+
totalCost: 0,
|
|
59
|
+
pricingFound: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const inputRate = resolvePerMillionRate(pricing.input, inputTokens);
|
|
63
|
+
const outputRate = resolvePerMillionRate(pricing.output, outputTokens);
|
|
64
|
+
const inputCost = (inputTokens / kTokensPerMillion) * inputRate;
|
|
65
|
+
const outputCost = (outputTokens / kTokensPerMillion) * outputRate;
|
|
66
|
+
const cacheReadCost = 0;
|
|
67
|
+
const cacheWriteRate = resolvePerMillionRate(pricing.input, cacheWriteTokens);
|
|
68
|
+
const cacheWriteCost = (cacheWriteTokens / kTokensPerMillion) * cacheWriteRate;
|
|
69
|
+
return {
|
|
70
|
+
inputCost,
|
|
71
|
+
outputCost,
|
|
72
|
+
cacheReadCost,
|
|
73
|
+
cacheWriteCost,
|
|
74
|
+
totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost,
|
|
75
|
+
pricingFound: true,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
kGlobalModelPricing,
|
|
81
|
+
deriveCostBreakdown,
|
|
82
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const safeAlterTable = (database, sql) => {
|
|
2
|
+
try {
|
|
3
|
+
database.exec(sql);
|
|
4
|
+
} catch (err) {
|
|
5
|
+
const message = String(err?.message || "").toLowerCase();
|
|
6
|
+
if (!message.includes("duplicate column name")) throw err;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ensureSchema = (database) => {
|
|
11
|
+
database.exec("PRAGMA journal_mode=WAL;");
|
|
12
|
+
database.exec("PRAGMA synchronous=NORMAL;");
|
|
13
|
+
database.exec("PRAGMA busy_timeout=5000;");
|
|
14
|
+
database.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
timestamp INTEGER NOT NULL,
|
|
18
|
+
session_id TEXT,
|
|
19
|
+
session_key TEXT,
|
|
20
|
+
run_id TEXT,
|
|
21
|
+
provider TEXT NOT NULL,
|
|
22
|
+
model TEXT NOT NULL,
|
|
23
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
total_tokens INTEGER NOT NULL DEFAULT 0
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
database.exec(`
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_ts
|
|
32
|
+
ON usage_events(timestamp DESC);
|
|
33
|
+
`);
|
|
34
|
+
database.exec(`
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_session
|
|
36
|
+
ON usage_events(session_id);
|
|
37
|
+
`);
|
|
38
|
+
safeAlterTable(
|
|
39
|
+
database,
|
|
40
|
+
"ALTER TABLE usage_events ADD COLUMN session_key TEXT;",
|
|
41
|
+
);
|
|
42
|
+
database.exec(`
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_session_key
|
|
44
|
+
ON usage_events(session_key);
|
|
45
|
+
`);
|
|
46
|
+
database.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS usage_daily (
|
|
48
|
+
date TEXT NOT NULL,
|
|
49
|
+
model TEXT NOT NULL,
|
|
50
|
+
provider TEXT,
|
|
51
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
PRIMARY KEY (date, model)
|
|
58
|
+
);
|
|
59
|
+
`);
|
|
60
|
+
database.exec(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS tool_events (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
timestamp INTEGER NOT NULL,
|
|
64
|
+
session_id TEXT,
|
|
65
|
+
session_key TEXT,
|
|
66
|
+
tool_name TEXT NOT NULL,
|
|
67
|
+
success INTEGER NOT NULL DEFAULT 1,
|
|
68
|
+
duration_ms INTEGER
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
database.exec(`
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_session
|
|
73
|
+
ON tool_events(session_id);
|
|
74
|
+
`);
|
|
75
|
+
safeAlterTable(
|
|
76
|
+
database,
|
|
77
|
+
"ALTER TABLE tool_events ADD COLUMN session_key TEXT;",
|
|
78
|
+
);
|
|
79
|
+
database.exec(`
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_session_key
|
|
81
|
+
ON tool_events(session_key);
|
|
82
|
+
`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
ensureSchema,
|
|
87
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
const {
|
|
2
|
+
kDefaultSessionLimit,
|
|
3
|
+
kMaxSessionLimit,
|
|
4
|
+
coerceInt,
|
|
5
|
+
clampInt,
|
|
6
|
+
getUsageMetricsFromEventRow,
|
|
7
|
+
} = require("./shared");
|
|
8
|
+
|
|
9
|
+
const getSessionsList = ({ database, limit = kDefaultSessionLimit } = {}) => {
|
|
10
|
+
const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit);
|
|
11
|
+
const rows = database
|
|
12
|
+
.prepare(`
|
|
13
|
+
SELECT
|
|
14
|
+
COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref,
|
|
15
|
+
MAX(session_key) AS session_key,
|
|
16
|
+
MAX(session_id) AS session_id,
|
|
17
|
+
MIN(timestamp) AS first_activity_ms,
|
|
18
|
+
MAX(timestamp) AS last_activity_ms,
|
|
19
|
+
COUNT(*) AS turn_count,
|
|
20
|
+
SUM(input_tokens) AS input_tokens,
|
|
21
|
+
SUM(output_tokens) AS output_tokens,
|
|
22
|
+
SUM(cache_read_tokens) AS cache_read_tokens,
|
|
23
|
+
SUM(cache_write_tokens) AS cache_write_tokens,
|
|
24
|
+
SUM(total_tokens) AS total_tokens
|
|
25
|
+
FROM usage_events
|
|
26
|
+
WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL
|
|
27
|
+
GROUP BY session_ref
|
|
28
|
+
ORDER BY last_activity_ms DESC
|
|
29
|
+
LIMIT $limit
|
|
30
|
+
`)
|
|
31
|
+
.all({ $limit: safeLimit });
|
|
32
|
+
return rows.map((row) => {
|
|
33
|
+
const eventRows = database
|
|
34
|
+
.prepare(`
|
|
35
|
+
SELECT
|
|
36
|
+
model,
|
|
37
|
+
input_tokens,
|
|
38
|
+
output_tokens,
|
|
39
|
+
cache_read_tokens,
|
|
40
|
+
cache_write_tokens,
|
|
41
|
+
total_tokens
|
|
42
|
+
FROM usage_events
|
|
43
|
+
WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
|
|
44
|
+
`)
|
|
45
|
+
.all({ $sessionRef: row.session_ref });
|
|
46
|
+
let totalCost = 0;
|
|
47
|
+
const modelTokenTotals = new Map();
|
|
48
|
+
for (const eventRow of eventRows) {
|
|
49
|
+
const metrics = getUsageMetricsFromEventRow(eventRow);
|
|
50
|
+
totalCost += metrics.totalCost;
|
|
51
|
+
const model = String(eventRow.model || "");
|
|
52
|
+
modelTokenTotals.set(model, (modelTokenTotals.get(model) || 0) + metrics.totalTokens);
|
|
53
|
+
}
|
|
54
|
+
const dominantModel = Array.from(modelTokenTotals.entries())
|
|
55
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] || "";
|
|
56
|
+
return {
|
|
57
|
+
sessionId: row.session_ref,
|
|
58
|
+
sessionKey: String(row.session_key || ""),
|
|
59
|
+
rawSessionId: String(row.session_id || ""),
|
|
60
|
+
firstActivityMs: coerceInt(row.first_activity_ms),
|
|
61
|
+
lastActivityMs: coerceInt(row.last_activity_ms),
|
|
62
|
+
durationMs: Math.max(
|
|
63
|
+
0,
|
|
64
|
+
coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms),
|
|
65
|
+
),
|
|
66
|
+
turnCount: coerceInt(row.turn_count),
|
|
67
|
+
inputTokens: coerceInt(row.input_tokens),
|
|
68
|
+
outputTokens: coerceInt(row.output_tokens),
|
|
69
|
+
cacheReadTokens: coerceInt(row.cache_read_tokens),
|
|
70
|
+
cacheWriteTokens: coerceInt(row.cache_write_tokens),
|
|
71
|
+
totalTokens: coerceInt(row.total_tokens),
|
|
72
|
+
totalCost,
|
|
73
|
+
dominantModel,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getSessionDetail = ({ database, sessionId }) => {
|
|
79
|
+
const safeSessionRef = String(sessionId || "").trim();
|
|
80
|
+
if (!safeSessionRef) return null;
|
|
81
|
+
const summaryRow = database
|
|
82
|
+
.prepare(`
|
|
83
|
+
SELECT
|
|
84
|
+
MAX(session_key) AS session_key,
|
|
85
|
+
MAX(session_id) AS session_id,
|
|
86
|
+
MIN(timestamp) AS first_activity_ms,
|
|
87
|
+
MAX(timestamp) AS last_activity_ms,
|
|
88
|
+
COUNT(*) AS turn_count,
|
|
89
|
+
SUM(input_tokens) AS input_tokens,
|
|
90
|
+
SUM(output_tokens) AS output_tokens,
|
|
91
|
+
SUM(cache_read_tokens) AS cache_read_tokens,
|
|
92
|
+
SUM(cache_write_tokens) AS cache_write_tokens,
|
|
93
|
+
SUM(total_tokens) AS total_tokens
|
|
94
|
+
FROM usage_events
|
|
95
|
+
WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
|
|
96
|
+
`)
|
|
97
|
+
.get({ $sessionRef: safeSessionRef });
|
|
98
|
+
if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null;
|
|
99
|
+
|
|
100
|
+
const modelEvents = database
|
|
101
|
+
.prepare(`
|
|
102
|
+
SELECT
|
|
103
|
+
provider,
|
|
104
|
+
model,
|
|
105
|
+
input_tokens,
|
|
106
|
+
output_tokens,
|
|
107
|
+
cache_read_tokens,
|
|
108
|
+
cache_write_tokens,
|
|
109
|
+
total_tokens
|
|
110
|
+
FROM usage_events
|
|
111
|
+
WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
|
|
112
|
+
`)
|
|
113
|
+
.all({ $sessionRef: safeSessionRef });
|
|
114
|
+
const byProviderModel = new Map();
|
|
115
|
+
for (const eventRow of modelEvents) {
|
|
116
|
+
const provider = String(eventRow.provider || "unknown");
|
|
117
|
+
const model = String(eventRow.model || "unknown");
|
|
118
|
+
const mapKey = `${provider}\u0000${model}`;
|
|
119
|
+
if (!byProviderModel.has(mapKey)) {
|
|
120
|
+
byProviderModel.set(mapKey, {
|
|
121
|
+
provider,
|
|
122
|
+
model,
|
|
123
|
+
turnCount: 0,
|
|
124
|
+
inputTokens: 0,
|
|
125
|
+
outputTokens: 0,
|
|
126
|
+
cacheReadTokens: 0,
|
|
127
|
+
cacheWriteTokens: 0,
|
|
128
|
+
totalTokens: 0,
|
|
129
|
+
totalCost: 0,
|
|
130
|
+
inputCost: 0,
|
|
131
|
+
outputCost: 0,
|
|
132
|
+
cacheReadCost: 0,
|
|
133
|
+
cacheWriteCost: 0,
|
|
134
|
+
pricingFound: false,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const aggregate = byProviderModel.get(mapKey);
|
|
138
|
+
const metrics = getUsageMetricsFromEventRow(eventRow);
|
|
139
|
+
aggregate.turnCount += 1;
|
|
140
|
+
aggregate.inputTokens += metrics.inputTokens;
|
|
141
|
+
aggregate.outputTokens += metrics.outputTokens;
|
|
142
|
+
aggregate.cacheReadTokens += metrics.cacheReadTokens;
|
|
143
|
+
aggregate.cacheWriteTokens += metrics.cacheWriteTokens;
|
|
144
|
+
aggregate.totalTokens += metrics.totalTokens;
|
|
145
|
+
aggregate.totalCost += metrics.totalCost;
|
|
146
|
+
aggregate.inputCost += metrics.inputCost;
|
|
147
|
+
aggregate.outputCost += metrics.outputCost;
|
|
148
|
+
aggregate.cacheReadCost += metrics.cacheReadCost;
|
|
149
|
+
aggregate.cacheWriteCost += metrics.cacheWriteCost;
|
|
150
|
+
aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound;
|
|
151
|
+
}
|
|
152
|
+
const modelRows = Array.from(byProviderModel.values()).sort(
|
|
153
|
+
(a, b) => b.totalTokens - a.totalTokens,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const toolRows = database
|
|
157
|
+
.prepare(`
|
|
158
|
+
SELECT
|
|
159
|
+
tool_name,
|
|
160
|
+
COUNT(*) AS call_count,
|
|
161
|
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,
|
|
162
|
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count,
|
|
163
|
+
AVG(duration_ms) AS avg_duration_ms,
|
|
164
|
+
MIN(duration_ms) AS min_duration_ms,
|
|
165
|
+
MAX(duration_ms) AS max_duration_ms
|
|
166
|
+
FROM tool_events
|
|
167
|
+
WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
|
|
168
|
+
GROUP BY tool_name
|
|
169
|
+
ORDER BY call_count DESC
|
|
170
|
+
`)
|
|
171
|
+
.all({ $sessionRef: safeSessionRef })
|
|
172
|
+
.map((row) => {
|
|
173
|
+
const callCount = coerceInt(row.call_count);
|
|
174
|
+
const successCount = coerceInt(row.success_count);
|
|
175
|
+
const errorCount = coerceInt(row.error_count);
|
|
176
|
+
return {
|
|
177
|
+
toolName: row.tool_name,
|
|
178
|
+
callCount,
|
|
179
|
+
successCount,
|
|
180
|
+
errorCount,
|
|
181
|
+
errorRate: callCount > 0 ? errorCount / callCount : 0,
|
|
182
|
+
avgDurationMs: Number(row.avg_duration_ms || 0),
|
|
183
|
+
minDurationMs: coerceInt(row.min_duration_ms),
|
|
184
|
+
maxDurationMs: coerceInt(row.max_duration_ms),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const firstActivityMs = coerceInt(summaryRow.first_activity_ms);
|
|
189
|
+
const lastActivityMs = coerceInt(summaryRow.last_activity_ms);
|
|
190
|
+
const totalCost = modelRows.reduce(
|
|
191
|
+
(sum, modelRow) => sum + Number(modelRow.totalCost || 0),
|
|
192
|
+
0,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
sessionId: safeSessionRef,
|
|
197
|
+
sessionKey: String(summaryRow.session_key || ""),
|
|
198
|
+
rawSessionId: String(summaryRow.session_id || ""),
|
|
199
|
+
firstActivityMs,
|
|
200
|
+
lastActivityMs,
|
|
201
|
+
durationMs: Math.max(0, lastActivityMs - firstActivityMs),
|
|
202
|
+
turnCount: coerceInt(summaryRow.turn_count),
|
|
203
|
+
inputTokens: coerceInt(summaryRow.input_tokens),
|
|
204
|
+
outputTokens: coerceInt(summaryRow.output_tokens),
|
|
205
|
+
cacheReadTokens: coerceInt(summaryRow.cache_read_tokens),
|
|
206
|
+
cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens),
|
|
207
|
+
totalTokens: coerceInt(summaryRow.total_tokens),
|
|
208
|
+
totalCost,
|
|
209
|
+
modelBreakdown: modelRows,
|
|
210
|
+
toolUsage: toolRows,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
getSessionsList,
|
|
216
|
+
getSessionDetail,
|
|
217
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const { deriveCostBreakdown } = require("./pricing");
|
|
2
|
+
|
|
3
|
+
const kDefaultSessionLimit = 50;
|
|
4
|
+
const kMaxSessionLimit = 200;
|
|
5
|
+
const kDefaultDays = 30;
|
|
6
|
+
const kDefaultMaxPoints = 100;
|
|
7
|
+
const kMaxMaxPoints = 1000;
|
|
8
|
+
const kDayMs = 24 * 60 * 60 * 1000;
|
|
9
|
+
const kUtcTimeZone = "UTC";
|
|
10
|
+
const kDayKeyFormatterCache = new Map();
|
|
11
|
+
|
|
12
|
+
const coerceInt = (value, fallbackValue = 0) => {
|
|
13
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : fallbackValue;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const clampInt = (value, minValue, maxValue, fallbackValue) =>
|
|
18
|
+
Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue)));
|
|
19
|
+
|
|
20
|
+
const normalizeTimeZone = (value) => {
|
|
21
|
+
const raw = String(value || "").trim();
|
|
22
|
+
if (!raw) return kUtcTimeZone;
|
|
23
|
+
try {
|
|
24
|
+
new Intl.DateTimeFormat("en-US", { timeZone: raw });
|
|
25
|
+
return raw;
|
|
26
|
+
} catch {
|
|
27
|
+
return kUtcTimeZone;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const getDayKeyFormatter = (timeZone) => {
|
|
32
|
+
if (kDayKeyFormatterCache.has(timeZone)) {
|
|
33
|
+
return kDayKeyFormatterCache.get(timeZone);
|
|
34
|
+
}
|
|
35
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
36
|
+
timeZone,
|
|
37
|
+
year: "numeric",
|
|
38
|
+
month: "2-digit",
|
|
39
|
+
day: "2-digit",
|
|
40
|
+
});
|
|
41
|
+
kDayKeyFormatterCache.set(timeZone, formatter);
|
|
42
|
+
return formatter;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const toTimeZoneDayKey = (timestampMs, timeZone) => {
|
|
46
|
+
const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs));
|
|
47
|
+
const year = parts.find((part) => part.type === "year")?.value || "0000";
|
|
48
|
+
const month = parts.find((part) => part.type === "month")?.value || "01";
|
|
49
|
+
const day = parts.find((part) => part.type === "day")?.value || "01";
|
|
50
|
+
return `${year}-${month}-${day}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10);
|
|
54
|
+
|
|
55
|
+
const getPeriodRange = (days, timeZone = kUtcTimeZone) => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const safeDays = clampInt(days, 1, 3650, kDefaultDays);
|
|
58
|
+
const startMs = now - safeDays * kDayMs;
|
|
59
|
+
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
|
60
|
+
const startDay = normalizedTimeZone === kUtcTimeZone
|
|
61
|
+
? toDayKey(startMs)
|
|
62
|
+
: toTimeZoneDayKey(startMs, normalizedTimeZone);
|
|
63
|
+
return { now, safeDays, startDay, timeZone: normalizedTimeZone };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getUsageMetricsFromEventRow = (row) => {
|
|
67
|
+
const inputTokens = coerceInt(row.input_tokens);
|
|
68
|
+
const outputTokens = coerceInt(row.output_tokens);
|
|
69
|
+
const cacheReadTokens = coerceInt(row.cache_read_tokens);
|
|
70
|
+
const cacheWriteTokens = coerceInt(row.cache_write_tokens);
|
|
71
|
+
const totalTokens =
|
|
72
|
+
coerceInt(row.total_tokens) ||
|
|
73
|
+
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
74
|
+
const cost = deriveCostBreakdown({
|
|
75
|
+
inputTokens,
|
|
76
|
+
outputTokens,
|
|
77
|
+
cacheReadTokens,
|
|
78
|
+
cacheWriteTokens,
|
|
79
|
+
model: row.model,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
inputTokens,
|
|
83
|
+
outputTokens,
|
|
84
|
+
cacheReadTokens,
|
|
85
|
+
cacheWriteTokens,
|
|
86
|
+
totalTokens,
|
|
87
|
+
...cost,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const parseAgentAndSourceFromSessionRef = (sessionRef) => {
|
|
92
|
+
const raw = String(sessionRef || "").trim();
|
|
93
|
+
if (!raw) {
|
|
94
|
+
return { agent: "unknown", source: "chat" };
|
|
95
|
+
}
|
|
96
|
+
const parts = raw.split(":");
|
|
97
|
+
const agent =
|
|
98
|
+
parts[0] === "agent" && String(parts[1] || "").trim()
|
|
99
|
+
? String(parts[1] || "").trim()
|
|
100
|
+
: "unknown";
|
|
101
|
+
const source = parts.includes("hook")
|
|
102
|
+
? "hooks"
|
|
103
|
+
: parts.includes("cron")
|
|
104
|
+
? "cron"
|
|
105
|
+
: "chat";
|
|
106
|
+
return { agent, source };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const downsamplePoints = (points, maxPoints) => {
|
|
110
|
+
if (points.length <= maxPoints) return points;
|
|
111
|
+
const stride = Math.ceil(points.length / maxPoints);
|
|
112
|
+
const sampled = [];
|
|
113
|
+
for (let index = 0; index < points.length; index += stride) {
|
|
114
|
+
sampled.push(points[index]);
|
|
115
|
+
}
|
|
116
|
+
const lastPoint = points[points.length - 1];
|
|
117
|
+
if (sampled[sampled.length - 1]?.timestamp !== lastPoint.timestamp) {
|
|
118
|
+
sampled.push(lastPoint);
|
|
119
|
+
}
|
|
120
|
+
return sampled;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
kDefaultSessionLimit,
|
|
125
|
+
kMaxSessionLimit,
|
|
126
|
+
kDefaultDays,
|
|
127
|
+
kDefaultMaxPoints,
|
|
128
|
+
kMaxMaxPoints,
|
|
129
|
+
kDayMs,
|
|
130
|
+
kUtcTimeZone,
|
|
131
|
+
coerceInt,
|
|
132
|
+
clampInt,
|
|
133
|
+
toTimeZoneDayKey,
|
|
134
|
+
toDayKey,
|
|
135
|
+
getPeriodRange,
|
|
136
|
+
getUsageMetricsFromEventRow,
|
|
137
|
+
parseAgentAndSourceFromSessionRef,
|
|
138
|
+
downsamplePoints,
|
|
139
|
+
};
|