@alashi/cli 0.11.0 → 0.12.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/dist/cli.mjs +50 -9
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +311 -29
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{store-VoJe_U1Q.mjs → store-BNZqhcwm.mjs} +739 -70
- package/dist/store-BNZqhcwm.mjs.map +1 -0
- package/package.json +3 -3
- package/templates/basic-app/index.mjs +1 -1
- package/templates/basic-app/package.json +1 -1
- package/dist/store-VoJe_U1Q.mjs.map +0 -1
|
@@ -1,16 +1,352 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
3
|
+
import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
|
|
2
4
|
import semver from "semver";
|
|
3
5
|
import { execFile } from "node:child_process";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
8
|
import { promisify } from "node:util";
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
10
|
import { z } from "zod";
|
|
11
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
10
12
|
import { Hono } from "hono";
|
|
11
13
|
import { streamSSE } from "hono/streaming";
|
|
12
14
|
import { Cron } from "croner";
|
|
13
15
|
import { homedir } from "node:os";
|
|
16
|
+
//#region src/auth.ts
|
|
17
|
+
/** Session cookie lifetime. Fixed, not sliding: re-login once a month. */
|
|
18
|
+
const SESSION_TTL_MS = 2592e6;
|
|
19
|
+
const SESSION_COOKIE = "alashi_session";
|
|
20
|
+
/** Recognizable in `Authorization: Bearer ...` and in shell history. */
|
|
21
|
+
const TOKEN_PREFIX = "alashi_tok_";
|
|
22
|
+
function sha256(value) {
|
|
23
|
+
return createHash("sha256").update(value).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
/** Constant-time compare of two hex digests of equal length. */
|
|
26
|
+
function hashEquals(a, b) {
|
|
27
|
+
const bufA = Buffer.from(a, "hex");
|
|
28
|
+
const bufB = Buffer.from(b, "hex");
|
|
29
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
30
|
+
}
|
|
31
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
32
|
+
"localhost",
|
|
33
|
+
"127.0.0.1",
|
|
34
|
+
"::1"
|
|
35
|
+
]);
|
|
36
|
+
function isLoopbackAddress(addr) {
|
|
37
|
+
const bare = addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
38
|
+
return bare === "::1" || bare.startsWith("127.");
|
|
39
|
+
}
|
|
40
|
+
const FORWARDING_HEADERS = [
|
|
41
|
+
"x-forwarded-for",
|
|
42
|
+
"x-forwarded-host",
|
|
43
|
+
"x-forwarded-proto",
|
|
44
|
+
"x-real-ip",
|
|
45
|
+
"forwarded"
|
|
46
|
+
];
|
|
47
|
+
/** True when the request carries any reverse-proxy forwarding header. */
|
|
48
|
+
function hasForwardingHeaders(headers) {
|
|
49
|
+
return FORWARDING_HEADERS.some((name) => headers.get(name) !== null);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The console heuristic: is this request verifiably from the local machine?
|
|
53
|
+
*
|
|
54
|
+
* Requires all three of: a loopback peer address, a local request hostname,
|
|
55
|
+
* and no forwarding headers — because tunnels (ngrok, cloudflared, `tailscale
|
|
56
|
+
* serve`) connect from loopback too, and what gives them away is the forwarded
|
|
57
|
+
* hostname or the forwarding headers they add. A proxy configured to strip
|
|
58
|
+
* both is indistinguishable from a local request; `mode: "required"` exists
|
|
59
|
+
* for anyone deliberately exposing the host.
|
|
60
|
+
*
|
|
61
|
+
* `hostname` is the request URL's hostname (which the server derives from the
|
|
62
|
+
* Host header) rather than the raw header, because in-process requests carry
|
|
63
|
+
* no Host header at all. `remoteAddress === undefined` means an in-process
|
|
64
|
+
* request (tests, the host calling itself) — there is no socket, so it is by
|
|
65
|
+
* definition local.
|
|
66
|
+
*/
|
|
67
|
+
function isConsoleRequest(hostname, headers, remoteAddress) {
|
|
68
|
+
if (remoteAddress !== void 0 && !isLoopbackAddress(remoteAddress)) return false;
|
|
69
|
+
if (hasForwardingHeaders(headers)) return false;
|
|
70
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase().replace(/^\[|\]$/g, ""));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The access level a path demands. Deny-by-default shape: everything is at
|
|
74
|
+
* least `user` unless it is explicitly public, and the routes that change
|
|
75
|
+
* host state or spend-relevant settings (config, budgets, manual job runs)
|
|
76
|
+
* are `owner`.
|
|
77
|
+
*/
|
|
78
|
+
function requiredLevel(path, publicApps) {
|
|
79
|
+
if (path === "/login" || path === "/logout" || path === "/api/me" || path === "/favicon.ico") return "public";
|
|
80
|
+
if (path === "/assets" || path.startsWith("/assets/")) return "public";
|
|
81
|
+
if (path.startsWith("/app/")) {
|
|
82
|
+
const name = path.slice(5).split("/", 1)[0] ?? "";
|
|
83
|
+
return publicApps.includes(name) ? "public" : "user";
|
|
84
|
+
}
|
|
85
|
+
if (path.startsWith("/settings/")) return "owner";
|
|
86
|
+
if (/^\/api\/apps\/[^/]+\/(config|budget)$/.test(path)) return "owner";
|
|
87
|
+
if (/^\/api\/apps\/[^/]+\/jobs\/[^/]+\/run$/.test(path)) return "owner";
|
|
88
|
+
return "user";
|
|
89
|
+
}
|
|
90
|
+
function meetsLevel(identity, required) {
|
|
91
|
+
return required !== "owner" || identity.level === "owner";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Host authentication: bearer tokens and browser sessions persisted in
|
|
95
|
+
* `auth.json` (hashes only), plus an in-memory one-time boot login token.
|
|
96
|
+
* Writes are serialized so concurrent logins cannot lose each other's update.
|
|
97
|
+
*/
|
|
98
|
+
var AuthService = class {
|
|
99
|
+
store;
|
|
100
|
+
queue = Promise.resolve();
|
|
101
|
+
bootHash;
|
|
102
|
+
constructor(store) {
|
|
103
|
+
this.store = store;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Mints the one-time login secret printed at serve startup. In-memory only
|
|
107
|
+
* and consumed by the first successful login; restart the host (or mint a
|
|
108
|
+
* real token) to get another.
|
|
109
|
+
*/
|
|
110
|
+
issueBootToken() {
|
|
111
|
+
const secret = `${TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
|
|
112
|
+
this.bootHash = sha256(secret);
|
|
113
|
+
return secret;
|
|
114
|
+
}
|
|
115
|
+
/** Creates a persistent owner bearer token; the secret is only returned here. */
|
|
116
|
+
async mintToken(label) {
|
|
117
|
+
const secret = `${TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
|
|
118
|
+
const record = {
|
|
119
|
+
id: randomBytes(6).toString("hex"),
|
|
120
|
+
...label !== void 0 && label !== "" && { label },
|
|
121
|
+
hash: sha256(secret),
|
|
122
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
123
|
+
};
|
|
124
|
+
await this.mutate((state) => {
|
|
125
|
+
state.tokens.push(record);
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
id: record.id,
|
|
129
|
+
secret
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async listTokens() {
|
|
133
|
+
const { tokens } = await this.store.readAuthState();
|
|
134
|
+
return tokens.map(({ hash: _hash, ...info }) => info);
|
|
135
|
+
}
|
|
136
|
+
/** Revokes a token and every session it created is left alone: sessions expire on their own. */
|
|
137
|
+
async revokeToken(id) {
|
|
138
|
+
let found = false;
|
|
139
|
+
await this.mutate((state) => {
|
|
140
|
+
const before = state.tokens.length;
|
|
141
|
+
state.tokens = state.tokens.filter((token) => token.id !== id);
|
|
142
|
+
found = state.tokens.length !== before;
|
|
143
|
+
});
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Exchanges a login secret (boot token or minted token) for an identity.
|
|
148
|
+
* A boot token is consumed by success; minted tokens are reusable.
|
|
149
|
+
*/
|
|
150
|
+
async redeemLoginSecret(secret) {
|
|
151
|
+
const hash = sha256(secret);
|
|
152
|
+
if (this.bootHash !== void 0 && hashEquals(hash, this.bootHash)) {
|
|
153
|
+
this.bootHash = void 0;
|
|
154
|
+
return {
|
|
155
|
+
level: "owner",
|
|
156
|
+
via: "boot",
|
|
157
|
+
name: "owner"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return this.tokenIdentity(hash);
|
|
161
|
+
}
|
|
162
|
+
/** Resolves `Authorization: Bearer <secret>`; undefined when absent or invalid. */
|
|
163
|
+
async bearerIdentity(authorization) {
|
|
164
|
+
if (authorization === void 0 || !authorization.toLowerCase().startsWith("bearer ")) return;
|
|
165
|
+
return this.tokenIdentity(sha256(authorization.slice(7).trim()));
|
|
166
|
+
}
|
|
167
|
+
async tokenIdentity(hash) {
|
|
168
|
+
const { tokens } = await this.store.readAuthState();
|
|
169
|
+
const token = tokens.find((entry) => hashEquals(entry.hash, hash));
|
|
170
|
+
if (!token) return void 0;
|
|
171
|
+
this.mutate((state) => {
|
|
172
|
+
const entry = state.tokens.find((candidate) => candidate.id === token.id);
|
|
173
|
+
if (entry) entry.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
174
|
+
}).catch(() => void 0);
|
|
175
|
+
return {
|
|
176
|
+
level: "owner",
|
|
177
|
+
via: "token",
|
|
178
|
+
name: token.label ?? `token ${token.id}`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Creates a session for an already-verified identity; returns the cookie value. */
|
|
182
|
+
async createSession(identity, at = /* @__PURE__ */ new Date()) {
|
|
183
|
+
const value = randomBytes(24).toString("hex");
|
|
184
|
+
const record = {
|
|
185
|
+
hash: sha256(value),
|
|
186
|
+
name: identity.name,
|
|
187
|
+
level: identity.level,
|
|
188
|
+
createdAt: at.toISOString(),
|
|
189
|
+
expiresAt: new Date(at.getTime() + SESSION_TTL_MS).toISOString()
|
|
190
|
+
};
|
|
191
|
+
await this.mutate((state) => {
|
|
192
|
+
state.sessions.push(record);
|
|
193
|
+
});
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
/** Resolves a session cookie value; undefined when unknown or expired. */
|
|
197
|
+
async sessionIdentity(value, at = /* @__PURE__ */ new Date()) {
|
|
198
|
+
if (value === void 0 || value === "") return void 0;
|
|
199
|
+
const hash = sha256(value);
|
|
200
|
+
const { sessions } = await this.store.readAuthState();
|
|
201
|
+
const session = sessions.find((entry) => hashEquals(entry.hash, hash));
|
|
202
|
+
if (!session || session.expiresAt <= at.toISOString()) return void 0;
|
|
203
|
+
return {
|
|
204
|
+
level: session.level,
|
|
205
|
+
via: "session",
|
|
206
|
+
name: session.name
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Deletes the session behind a cookie value (logout). */
|
|
210
|
+
async destroySession(value) {
|
|
211
|
+
const hash = sha256(value);
|
|
212
|
+
await this.mutate((state) => {
|
|
213
|
+
state.sessions = state.sessions.filter((entry) => !hashEquals(entry.hash, hash));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** Read-modify-write of auth.json, serialized, with expired sessions pruned. */
|
|
217
|
+
mutate(change) {
|
|
218
|
+
const next = this.queue.then(async () => {
|
|
219
|
+
const state = await this.store.readAuthState();
|
|
220
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
221
|
+
state.sessions = state.sessions.filter((session) => session.expiresAt > now);
|
|
222
|
+
change(state);
|
|
223
|
+
await this.store.writeAuthState(state);
|
|
224
|
+
});
|
|
225
|
+
this.queue = next.catch(() => void 0);
|
|
226
|
+
return next;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
function wantsHtml(c) {
|
|
230
|
+
return c.req.method === "GET" && (c.req.header("accept") ?? "").includes("text/html");
|
|
231
|
+
}
|
|
232
|
+
/** Tailscale identity, when the config says the headers can be believed. */
|
|
233
|
+
function tailscaleIdentity(c, trusted) {
|
|
234
|
+
if (!trusted) return void 0;
|
|
235
|
+
const login = c.req.header("tailscale-user-login");
|
|
236
|
+
if (login === void 0 || login === "") return void 0;
|
|
237
|
+
return {
|
|
238
|
+
level: "user",
|
|
239
|
+
via: "tailscale",
|
|
240
|
+
name: c.req.header("tailscale-user-name") ?? login
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The host's front door. Resolves an identity for every request (bearer token,
|
|
245
|
+
* session cookie, tailscale headers, console heuristic — in that order) and
|
|
246
|
+
* enforces the path's required level. Auth config is read per request so
|
|
247
|
+
* settings changes apply without a restart.
|
|
248
|
+
*/
|
|
249
|
+
function createAuthMiddleware(store, auth, options = {}) {
|
|
250
|
+
let warnedProxyWhileOff = false;
|
|
251
|
+
return async (c, next) => {
|
|
252
|
+
const config = (await store.readGlobalConfig()).auth ?? {};
|
|
253
|
+
const mode = config.mode ?? "auto";
|
|
254
|
+
const required = requiredLevel(c.req.path, config.public ?? []);
|
|
255
|
+
if (mode === "off") {
|
|
256
|
+
if (!warnedProxyWhileOff && hasForwardingHeaders(c.req.raw.headers)) {
|
|
257
|
+
warnedProxyWhileOff = true;
|
|
258
|
+
console.warn("alashi: a request arrived through a proxy while auth.mode is \"off\" — this host appears to be exposed; set auth.mode to \"required\" in config.json");
|
|
259
|
+
}
|
|
260
|
+
c.set("identity", {
|
|
261
|
+
level: "owner",
|
|
262
|
+
via: "off",
|
|
263
|
+
name: "owner"
|
|
264
|
+
});
|
|
265
|
+
return next();
|
|
266
|
+
}
|
|
267
|
+
const identity = await auth.bearerIdentity(c.req.header("authorization")) ?? await auth.sessionIdentity(getCookie(c, "alashi_session")) ?? tailscaleIdentity(c, config.trustTailscaleHeaders === true) ?? (mode === "auto" && isConsoleRequest(new URL(c.req.url).hostname, c.req.raw.headers, options.getRemoteAddress?.(c)) ? {
|
|
268
|
+
level: "owner",
|
|
269
|
+
via: "console",
|
|
270
|
+
name: "owner"
|
|
271
|
+
} : void 0);
|
|
272
|
+
if (identity) c.set("identity", identity);
|
|
273
|
+
if (required === "public") return next();
|
|
274
|
+
if (identity && meetsLevel(identity, required)) return next();
|
|
275
|
+
if (wantsHtml(c)) return c.redirect("/login");
|
|
276
|
+
return c.json({ error: identity ? "This action requires the owner" : "Authentication required" }, identity ? 403 : 401);
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function loginPage(error) {
|
|
280
|
+
return `<!doctype html>
|
|
281
|
+
<html lang="en">
|
|
282
|
+
<head>
|
|
283
|
+
<meta charset="utf-8">
|
|
284
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
285
|
+
<title>alashi — sign in</title>
|
|
286
|
+
<link rel="stylesheet" href="/assets/v1/system.css">
|
|
287
|
+
</head>
|
|
288
|
+
<body>
|
|
289
|
+
<header class="topbar"><strong>alashi</strong></header>
|
|
290
|
+
<main class="narrow">
|
|
291
|
+
<h1>Sign in</h1>
|
|
292
|
+
<div class="panel">
|
|
293
|
+
${error ? "<p class=\"badge err\">Invalid or expired token</p>" : ""}
|
|
294
|
+
<p class="muted">Paste a login token — the one printed by <code>alashi serve</code>, or one minted with <code>alashi token create</code>.</p>
|
|
295
|
+
<form method="post" action="/login">
|
|
296
|
+
<p><input type="password" name="token" autocomplete="off" autofocus style="width:100%"></p>
|
|
297
|
+
<p><button type="submit">Sign in</button></p>
|
|
298
|
+
</form>
|
|
299
|
+
</div>
|
|
300
|
+
</main>
|
|
301
|
+
</body>
|
|
302
|
+
</html>
|
|
303
|
+
`;
|
|
304
|
+
}
|
|
305
|
+
function grantSession(c, value) {
|
|
306
|
+
setCookie(c, SESSION_COOKIE, value, {
|
|
307
|
+
path: "/",
|
|
308
|
+
httpOnly: true,
|
|
309
|
+
sameSite: "Lax",
|
|
310
|
+
maxAge: Math.floor(SESSION_TTL_MS / 1e3)
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The auth surface itself (all public in the policy map):
|
|
315
|
+
* GET /login login form; `?token=` redeems the printed one-time URL
|
|
316
|
+
* POST /login { token } (form-encoded) -> session cookie + redirect /
|
|
317
|
+
* GET /logout destroys the session, back to /login
|
|
318
|
+
* GET /api/me the resolved identity, or { identity: null }
|
|
319
|
+
*/
|
|
320
|
+
function registerAuthRoutes(app, auth) {
|
|
321
|
+
app.get("/login", async (c) => {
|
|
322
|
+
const token = c.req.query("token");
|
|
323
|
+
if (token === void 0) {
|
|
324
|
+
const existing = c.get("identity");
|
|
325
|
+
if (existing && existing.via !== "console") return c.redirect("/");
|
|
326
|
+
return c.html(loginPage());
|
|
327
|
+
}
|
|
328
|
+
const identity = await auth.redeemLoginSecret(token);
|
|
329
|
+
if (!identity) return c.html(loginPage("invalid"), 401);
|
|
330
|
+
grantSession(c, await auth.createSession(identity));
|
|
331
|
+
return c.redirect("/");
|
|
332
|
+
});
|
|
333
|
+
app.post("/login", async (c) => {
|
|
334
|
+
const body = await c.req.parseBody();
|
|
335
|
+
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
336
|
+
const identity = token === "" ? void 0 : await auth.redeemLoginSecret(token);
|
|
337
|
+
if (!identity) return c.html(loginPage("invalid"), 401);
|
|
338
|
+
grantSession(c, await auth.createSession(identity));
|
|
339
|
+
return c.redirect("/");
|
|
340
|
+
});
|
|
341
|
+
app.get("/logout", async (c) => {
|
|
342
|
+
const value = getCookie(c, SESSION_COOKIE);
|
|
343
|
+
if (value !== void 0) await auth.destroySession(value);
|
|
344
|
+
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
|
345
|
+
return c.redirect("/login");
|
|
346
|
+
});
|
|
347
|
+
app.get("/api/me", (c) => c.json({ identity: c.get("identity") ?? null }));
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
14
350
|
//#region src/usage.ts
|
|
15
351
|
/** Days of history kept in usage.json; older days are dropped on write. */
|
|
16
352
|
const USAGE_RETENTION_DAYS = 90;
|
|
@@ -21,6 +357,10 @@ const DAY_MS = 864e5;
|
|
|
21
357
|
function utcDay(at = /* @__PURE__ */ new Date()) {
|
|
22
358
|
return at.toISOString().slice(0, 10);
|
|
23
359
|
}
|
|
360
|
+
/** The UTC calendar month (`YYYY-MM`) a timestamp falls in. */
|
|
361
|
+
function utcMonth(at = /* @__PURE__ */ new Date()) {
|
|
362
|
+
return at.toISOString().slice(0, 7);
|
|
363
|
+
}
|
|
24
364
|
function round(value) {
|
|
25
365
|
return Number(value.toFixed(COST_PRECISION));
|
|
26
366
|
}
|
|
@@ -28,15 +368,98 @@ function round(value) {
|
|
|
28
368
|
function formatUsd(value) {
|
|
29
369
|
return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
|
|
30
370
|
}
|
|
371
|
+
/** Merges per-app summaries into the host-wide view. */
|
|
372
|
+
function combineSummaries(summaries) {
|
|
373
|
+
const combined = {
|
|
374
|
+
todayCostUsd: 0,
|
|
375
|
+
todayTurns: 0,
|
|
376
|
+
totalCostUsd: 0,
|
|
377
|
+
todayModels: {},
|
|
378
|
+
models: {},
|
|
379
|
+
months: [],
|
|
380
|
+
recorded: false
|
|
381
|
+
};
|
|
382
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
383
|
+
for (const summary of summaries) {
|
|
384
|
+
combined.todayCostUsd = round(combined.todayCostUsd + summary.todayCostUsd);
|
|
385
|
+
combined.todayTurns += summary.todayTurns;
|
|
386
|
+
combined.totalCostUsd = round(combined.totalCostUsd + summary.totalCostUsd);
|
|
387
|
+
combined.recorded ||= summary.recorded;
|
|
388
|
+
mergeTotals(combined.todayModels, summary.todayModels);
|
|
389
|
+
mergeTotals(combined.models, summary.models);
|
|
390
|
+
for (const month of summary.months) {
|
|
391
|
+
const target = byMonth.get(month.month) ?? {
|
|
392
|
+
month: month.month,
|
|
393
|
+
costUsd: 0,
|
|
394
|
+
turns: 0
|
|
395
|
+
};
|
|
396
|
+
target.costUsd = round(target.costUsd + month.costUsd);
|
|
397
|
+
target.turns += month.turns;
|
|
398
|
+
if (month.models !== void 0) {
|
|
399
|
+
target.models ??= {};
|
|
400
|
+
mergeTotals(target.models, month.models);
|
|
401
|
+
}
|
|
402
|
+
byMonth.set(month.month, target);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
combined.months = [...byMonth.values()].sort((a, b) => a.month.localeCompare(b.month));
|
|
406
|
+
return combined;
|
|
407
|
+
}
|
|
408
|
+
function emptyTotals() {
|
|
409
|
+
return {
|
|
410
|
+
inputTokens: 0,
|
|
411
|
+
outputTokens: 0,
|
|
412
|
+
cacheReadInputTokens: 0,
|
|
413
|
+
cacheCreationInputTokens: 0,
|
|
414
|
+
costUsd: 0
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function count(value) {
|
|
418
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
419
|
+
}
|
|
420
|
+
function addModelUsage(models, usage) {
|
|
421
|
+
for (const [model, turn] of Object.entries(usage)) {
|
|
422
|
+
const totals = models[model] ??= emptyTotals();
|
|
423
|
+
totals.inputTokens += count(turn.inputTokens);
|
|
424
|
+
totals.outputTokens += count(turn.outputTokens);
|
|
425
|
+
totals.cacheReadInputTokens += count(turn.cacheReadInputTokens);
|
|
426
|
+
totals.cacheCreationInputTokens += count(turn.cacheCreationInputTokens);
|
|
427
|
+
totals.costUsd = round(totals.costUsd + count(turn.costUsd));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function mergeTotals(into, from) {
|
|
431
|
+
for (const [model, totals] of Object.entries(from ?? {})) {
|
|
432
|
+
const target = into[model] ??= emptyTotals();
|
|
433
|
+
target.inputTokens += count(totals.inputTokens);
|
|
434
|
+
target.outputTokens += count(totals.outputTokens);
|
|
435
|
+
target.cacheReadInputTokens += count(totals.cacheReadInputTokens);
|
|
436
|
+
target.cacheCreationInputTokens += count(totals.cacheCreationInputTokens);
|
|
437
|
+
target.costUsd = round(target.costUsd + count(totals.costUsd));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function sanitizeModels(models) {
|
|
441
|
+
if (models === void 0) return void 0;
|
|
442
|
+
const clean = {};
|
|
443
|
+
for (const [model, totals] of Object.entries(models)) clean[model] = {
|
|
444
|
+
inputTokens: count(totals?.inputTokens),
|
|
445
|
+
outputTokens: count(totals?.outputTokens),
|
|
446
|
+
cacheReadInputTokens: count(totals?.cacheReadInputTokens),
|
|
447
|
+
cacheCreationInputTokens: count(totals?.cacheCreationInputTokens),
|
|
448
|
+
costUsd: round(count(totals?.costUsd))
|
|
449
|
+
};
|
|
450
|
+
return Object.keys(clean).length > 0 ? clean : void 0;
|
|
451
|
+
}
|
|
31
452
|
/**
|
|
32
|
-
* Per-app
|
|
33
|
-
* container (`apps/<name>/usage.json`, next to jobs.json and chat.json)
|
|
453
|
+
* Per-app AI spend and token usage, persisted as OS bookkeeping in the app
|
|
454
|
+
* container (`apps/<name>/usage.json`, next to jobs.json and chat.json):
|
|
455
|
+
* a per-UTC-day series trimmed to the retention window, plus per-month
|
|
456
|
+
* rollups that are kept forever.
|
|
34
457
|
*
|
|
35
|
-
* Cost
|
|
36
|
-
* marks optional: engines that
|
|
37
|
-
* Writes are serialized per app so concurrent turns cannot lose an
|
|
38
|
-
* and each app has its own queue so a slow write for one app never
|
|
39
|
-
* another.
|
|
458
|
+
* Cost and per-model tokens arrive from the `result` event, whose fields the
|
|
459
|
+
* SDK contract marks optional: engines that report neither simply contribute
|
|
460
|
+
* nothing. Writes are serialized per app so concurrent turns cannot lose an
|
|
461
|
+
* update, and each app has its own queue so a slow write for one app never
|
|
462
|
+
* delays another.
|
|
40
463
|
*/
|
|
41
464
|
var UsageRecorder = class {
|
|
42
465
|
store;
|
|
@@ -48,53 +471,136 @@ var UsageRecorder = class {
|
|
|
48
471
|
this.retentionDays = retentionDays;
|
|
49
472
|
}
|
|
50
473
|
/**
|
|
51
|
-
* Adds one turn's cost to the app's
|
|
52
|
-
* A missing, non-finite, or negative `costUsd` is
|
|
53
|
-
*
|
|
474
|
+
* Adds one turn's cost and token usage to the app's totals for the UTC day
|
|
475
|
+
* and month of `at`. A missing, non-finite, or negative `costUsd` is
|
|
476
|
+
* ignored; a turn that reports neither valid cost nor any model usage is
|
|
477
|
+
* not counted and nothing is written, so a provider that omits metering
|
|
478
|
+
* cannot corrupt the totals.
|
|
54
479
|
*/
|
|
55
|
-
record(appName,
|
|
56
|
-
|
|
480
|
+
record(appName, turn, at = /* @__PURE__ */ new Date()) {
|
|
481
|
+
const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
|
|
482
|
+
const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
|
|
483
|
+
if (!costValid && modelUsage === void 0) return Promise.resolve();
|
|
484
|
+
const costUsd = costValid ? turn.costUsd : 0;
|
|
57
485
|
return this.enqueue(appName, async () => {
|
|
58
|
-
const state = await this.store.readUsageState(appName);
|
|
486
|
+
const state = this.normalize(await this.store.readUsageState(appName));
|
|
59
487
|
const date = utcDay(at);
|
|
60
488
|
const day = state.days.find((entry) => entry.date === date);
|
|
61
|
-
if (day)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
489
|
+
if (day) this.apply(day, costUsd, modelUsage);
|
|
490
|
+
else {
|
|
491
|
+
const fresh = {
|
|
492
|
+
date,
|
|
493
|
+
costUsd: 0,
|
|
494
|
+
turns: 0
|
|
495
|
+
};
|
|
496
|
+
this.apply(fresh, costUsd, modelUsage);
|
|
497
|
+
state.days.push(fresh);
|
|
498
|
+
}
|
|
499
|
+
const monthKey = utcMonth(at);
|
|
500
|
+
const months = state.months ?? [];
|
|
501
|
+
const month = months.find((entry) => entry.month === monthKey);
|
|
502
|
+
if (month) this.apply(month, costUsd, modelUsage);
|
|
503
|
+
else {
|
|
504
|
+
const fresh = {
|
|
505
|
+
month: monthKey,
|
|
506
|
+
costUsd: 0,
|
|
507
|
+
turns: 0
|
|
508
|
+
};
|
|
509
|
+
this.apply(fresh, costUsd, modelUsage);
|
|
510
|
+
months.push(fresh);
|
|
511
|
+
}
|
|
512
|
+
state.months = months;
|
|
69
513
|
await this.store.writeUsageState(appName, this.prune(state, at));
|
|
70
514
|
});
|
|
71
515
|
}
|
|
72
|
-
/** The app's recorded
|
|
516
|
+
/** The app's recorded usage, oldest first, with expired days filtered out. */
|
|
73
517
|
async read(appName, at = /* @__PURE__ */ new Date()) {
|
|
74
|
-
return this.prune(await this.store.readUsageState(appName), at);
|
|
518
|
+
return this.prune(this.normalize(await this.store.readUsageState(appName)), at);
|
|
75
519
|
}
|
|
76
|
-
/** Today's spend plus the daily series, for the dashboard and the usage
|
|
520
|
+
/** Today's spend plus the daily/monthly series, for the dashboard and the usage endpoints. */
|
|
77
521
|
async summary(appName, at = /* @__PURE__ */ new Date()) {
|
|
78
|
-
const { days } = await this.read(appName, at);
|
|
522
|
+
const { days, months } = await this.read(appName, at);
|
|
79
523
|
const date = utcDay(at);
|
|
80
524
|
const today = days.find((entry) => entry.date === date);
|
|
525
|
+
const models = {};
|
|
526
|
+
for (const day of days) mergeTotals(models, day.models);
|
|
81
527
|
return {
|
|
82
528
|
date,
|
|
83
529
|
todayCostUsd: today?.costUsd ?? 0,
|
|
84
530
|
todayTurns: today?.turns ?? 0,
|
|
531
|
+
todayModels: today?.models ?? {},
|
|
85
532
|
totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
|
|
533
|
+
models,
|
|
86
534
|
retentionDays: this.retentionDays,
|
|
87
|
-
days
|
|
535
|
+
recorded: days.length > 0 || months.length > 0,
|
|
536
|
+
days,
|
|
537
|
+
months
|
|
88
538
|
};
|
|
89
539
|
}
|
|
90
|
-
|
|
540
|
+
apply(bucket, costUsd, modelUsage) {
|
|
541
|
+
bucket.costUsd = round(bucket.costUsd + costUsd);
|
|
542
|
+
bucket.turns += 1;
|
|
543
|
+
if (modelUsage !== void 0) {
|
|
544
|
+
bucket.models ??= {};
|
|
545
|
+
addModelUsage(bucket.models, modelUsage);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* One-time migration for files written before monthly rollups existed:
|
|
550
|
+
* absent `months` is seeded from the surviving days, so history inside the
|
|
551
|
+
* retention window is not lost to the rollups. After the first write the
|
|
552
|
+
* field is always present (possibly empty) and this is a no-op.
|
|
553
|
+
*/
|
|
554
|
+
normalize(state) {
|
|
555
|
+
if (state.months !== void 0) return {
|
|
556
|
+
days: state.days,
|
|
557
|
+
months: state.months
|
|
558
|
+
};
|
|
559
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
560
|
+
for (const day of state.days) {
|
|
561
|
+
if (typeof day?.date !== "string" || day.date.length < 7) continue;
|
|
562
|
+
const key = day.date.slice(0, 7);
|
|
563
|
+
const month = byMonth.get(key) ?? {
|
|
564
|
+
month: key,
|
|
565
|
+
costUsd: 0,
|
|
566
|
+
turns: 0
|
|
567
|
+
};
|
|
568
|
+
month.costUsd = round(month.costUsd + (Number.isFinite(day.costUsd) ? day.costUsd : 0));
|
|
569
|
+
month.turns += Number.isFinite(day.turns) ? day.turns : 0;
|
|
570
|
+
if (day.models !== void 0) {
|
|
571
|
+
month.models ??= {};
|
|
572
|
+
mergeTotals(month.models, day.models);
|
|
573
|
+
}
|
|
574
|
+
byMonth.set(key, month);
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
days: state.days,
|
|
578
|
+
months: [...byMonth.values()]
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
|
|
91
582
|
prune(state, at) {
|
|
92
583
|
const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
|
|
93
|
-
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
584
|
+
return {
|
|
585
|
+
days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
586
|
+
const models = sanitizeModels(entry.models);
|
|
587
|
+
return {
|
|
588
|
+
date: entry.date,
|
|
589
|
+
costUsd: round(entry.costUsd),
|
|
590
|
+
turns: Number.isFinite(entry.turns) ? entry.turns : 0,
|
|
591
|
+
...models !== void 0 && { models }
|
|
592
|
+
};
|
|
593
|
+
}).sort((a, b) => a.date.localeCompare(b.date)),
|
|
594
|
+
months: state.months.filter((entry) => typeof entry?.month === "string" && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
595
|
+
const models = sanitizeModels(entry.models);
|
|
596
|
+
return {
|
|
597
|
+
month: entry.month,
|
|
598
|
+
costUsd: round(entry.costUsd),
|
|
599
|
+
turns: Number.isFinite(entry.turns) ? entry.turns : 0,
|
|
600
|
+
...models !== void 0 && { models }
|
|
601
|
+
};
|
|
602
|
+
}).sort((a, b) => a.month.localeCompare(b.month))
|
|
603
|
+
};
|
|
98
604
|
}
|
|
99
605
|
enqueue(appName, work) {
|
|
100
606
|
const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
|
|
@@ -170,6 +676,7 @@ function budgetStatusFor(summary, dailyUsd) {
|
|
|
170
676
|
return {
|
|
171
677
|
date: summary.date,
|
|
172
678
|
spentUsd: summary.todayCostUsd,
|
|
679
|
+
recorded: summary.recorded,
|
|
173
680
|
...dailyUsd !== void 0 && {
|
|
174
681
|
dailyUsd,
|
|
175
682
|
remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
|
|
@@ -226,6 +733,9 @@ function checkSdkCompatibility(manifest) {
|
|
|
226
733
|
}
|
|
227
734
|
//#endregion
|
|
228
735
|
//#region src/loader.ts
|
|
736
|
+
function isRecord(value) {
|
|
737
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
738
|
+
}
|
|
229
739
|
function entryFromExports(exportsField) {
|
|
230
740
|
if (typeof exportsField === "string") return exportsField;
|
|
231
741
|
if (typeof exportsField !== "object" || exportsField === null) return void 0;
|
|
@@ -243,7 +753,7 @@ function entryFromExports(exportsField) {
|
|
|
243
753
|
* The manifest is always re-validated here — the host never trusts
|
|
244
754
|
* that an app used defineApp().
|
|
245
755
|
*/
|
|
246
|
-
async function loadApp(repoDir) {
|
|
756
|
+
async function loadApp(repoDir, options = {}) {
|
|
247
757
|
let pkg;
|
|
248
758
|
try {
|
|
249
759
|
pkg = JSON.parse(await readFile(join(repoDir, "package.json"), "utf8"));
|
|
@@ -262,15 +772,21 @@ async function loadApp(repoDir) {
|
|
|
262
772
|
}
|
|
263
773
|
const definition = mod.default;
|
|
264
774
|
if (typeof definition !== "object" || definition === null || !("manifest" in definition)) throw new AppSdkError("App entry must default-export an app definition (use defineApp() from @alashi/apps-sdk)");
|
|
265
|
-
const
|
|
775
|
+
const input = isRecord(definition.rawManifest) ? definition.rawManifest : definition.manifest;
|
|
776
|
+
const raw = input;
|
|
266
777
|
if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
|
|
267
778
|
name: typeof raw.name === "string" ? raw.name : entry,
|
|
268
779
|
sdkVersion: raw.sdkVersion
|
|
269
780
|
});
|
|
781
|
+
const { manifest, unknownKeys } = parseManifestWithWarnings(input);
|
|
782
|
+
const warnings = unknownKeys.map((key) => `ignoring unknown manifest key "${key}" — this host uses SDK ${SDK_VERSION}`);
|
|
783
|
+
const warn = options.warn ?? ((message) => console.warn(`[${manifest.name}] ${message}`));
|
|
784
|
+
for (const warning of warnings) warn(warning);
|
|
270
785
|
return {
|
|
271
786
|
definition,
|
|
272
|
-
manifest
|
|
273
|
-
entryPath
|
|
787
|
+
manifest,
|
|
788
|
+
entryPath,
|
|
789
|
+
warnings
|
|
274
790
|
};
|
|
275
791
|
}
|
|
276
792
|
//#endregion
|
|
@@ -481,7 +997,7 @@ async function installApp(store, source, options = {}) {
|
|
|
481
997
|
inspectDir = stageDir;
|
|
482
998
|
}
|
|
483
999
|
try {
|
|
484
|
-
const { manifest } = await loadApp(inspectDir);
|
|
1000
|
+
const { manifest, warnings } = await loadApp(inspectDir, { warn: () => {} });
|
|
485
1001
|
if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
|
|
486
1002
|
if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
|
|
487
1003
|
const repoDir = store.appRepoDir(manifest.name);
|
|
@@ -501,7 +1017,10 @@ async function installApp(store, source, options = {}) {
|
|
|
501
1017
|
updatedAt: now
|
|
502
1018
|
};
|
|
503
1019
|
await store.writeAppRecord(record);
|
|
504
|
-
return
|
|
1020
|
+
return {
|
|
1021
|
+
record,
|
|
1022
|
+
warnings
|
|
1023
|
+
};
|
|
505
1024
|
} finally {
|
|
506
1025
|
if (stageDir) await rm(stageDir, {
|
|
507
1026
|
recursive: true,
|
|
@@ -515,7 +1034,7 @@ async function updateApp(store, name) {
|
|
|
515
1034
|
const repoDir = store.appRepoDir(name);
|
|
516
1035
|
await updateSource(record.source, repoDir);
|
|
517
1036
|
await installDependencies(repoDir);
|
|
518
|
-
const { manifest } = await loadApp(repoDir);
|
|
1037
|
+
const { manifest, warnings } = await loadApp(repoDir, { warn: () => {} });
|
|
519
1038
|
if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
|
|
520
1039
|
const updated = {
|
|
521
1040
|
...record,
|
|
@@ -524,7 +1043,10 @@ async function updateApp(store, name) {
|
|
|
524
1043
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
525
1044
|
};
|
|
526
1045
|
await store.writeAppRecord(updated);
|
|
527
|
-
return
|
|
1046
|
+
return {
|
|
1047
|
+
record: updated,
|
|
1048
|
+
warnings
|
|
1049
|
+
};
|
|
528
1050
|
}
|
|
529
1051
|
async function removeApp(store, name, options = {}) {
|
|
530
1052
|
if (!await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is not installed`);
|
|
@@ -832,7 +1354,7 @@ function createLogger(prefix) {
|
|
|
832
1354
|
* chat turns, cron job handlers, and app routes alike — so spend is recorded
|
|
833
1355
|
* no matter who opened the session.
|
|
834
1356
|
*/
|
|
835
|
-
function meterSession(session,
|
|
1357
|
+
function meterSession(session, recordUsage) {
|
|
836
1358
|
return {
|
|
837
1359
|
get id() {
|
|
838
1360
|
return session.id;
|
|
@@ -841,7 +1363,10 @@ function meterSession(session, recordCost) {
|
|
|
841
1363
|
const events = session.send(input);
|
|
842
1364
|
return (async function* metered() {
|
|
843
1365
|
for await (const event of events) {
|
|
844
|
-
if (event.type === "result") await
|
|
1366
|
+
if (event.type === "result") await recordUsage({
|
|
1367
|
+
costUsd: event.costUsd,
|
|
1368
|
+
modelUsage: event.modelUsage
|
|
1369
|
+
});
|
|
845
1370
|
yield event;
|
|
846
1371
|
}
|
|
847
1372
|
})();
|
|
@@ -881,7 +1406,7 @@ function scopeProvider(provider, defaults) {
|
|
|
881
1406
|
...Object.keys(agents).length > 0 && { agents },
|
|
882
1407
|
...skillDirs.length > 0 && { skillDirs }
|
|
883
1408
|
});
|
|
884
|
-
return defaults.
|
|
1409
|
+
return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
|
|
885
1410
|
}
|
|
886
1411
|
};
|
|
887
1412
|
}
|
|
@@ -943,11 +1468,11 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
943
1468
|
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
944
1469
|
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
945
1470
|
const logger = createLogger(name);
|
|
946
|
-
const
|
|
1471
|
+
const recordUsage = usage ? async (turn) => {
|
|
947
1472
|
try {
|
|
948
|
-
await usage.record(name,
|
|
1473
|
+
await usage.record(name, turn);
|
|
949
1474
|
} catch (err) {
|
|
950
|
-
logger.warn("Failed to record AI
|
|
1475
|
+
logger.warn("Failed to record AI usage", { error: String(err) });
|
|
951
1476
|
}
|
|
952
1477
|
} : void 0;
|
|
953
1478
|
return {
|
|
@@ -963,7 +1488,7 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
963
1488
|
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
964
1489
|
...instructions !== void 0 && { instructions },
|
|
965
1490
|
cwd: dataDir,
|
|
966
|
-
...
|
|
1491
|
+
...recordUsage !== void 0 && { recordUsage },
|
|
967
1492
|
...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
|
|
968
1493
|
...!status.ready && { unreadyError: `App "${name}" is not configured: missing ${status.missing.join(", ")}. Set the missing keys in the dashboard settings or ${store.appConfigPath(name)}.` }
|
|
969
1494
|
})
|
|
@@ -1140,6 +1665,7 @@ function settingsPage(name) {
|
|
|
1140
1665
|
<main class="narrow">
|
|
1141
1666
|
<div id="banner" class="banner" hidden></div>
|
|
1142
1667
|
<div id="budget-card"></div>
|
|
1668
|
+
<div id="usage-card"></div>
|
|
1143
1669
|
<div id="cards"></div>
|
|
1144
1670
|
<button id="save" class="primary">Save changes</button><span id="status"></span>
|
|
1145
1671
|
<div id="manifest-cards"></div>
|
|
@@ -1316,7 +1842,17 @@ function renderBudget(b, message) {
|
|
|
1316
1842
|
},
|
|
1317
1843
|
});
|
|
1318
1844
|
|
|
1319
|
-
const
|
|
1845
|
+
const capNote = capped
|
|
1846
|
+
? usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
|
|
1847
|
+
: 'no cap set, spend is unlimited';
|
|
1848
|
+
// No ledger at all is an absence, not $0.0000: the host meters only sessions
|
|
1849
|
+
// opened through ctx.ai, so an app that tracks its own costs elsewhere can
|
|
1850
|
+
// report real spend while the host has recorded nothing for it.
|
|
1851
|
+
const spent = b.recorded === false
|
|
1852
|
+
? 'No AI spend recorded for this app yet (' + b.date + ', UTC) — ' + capNote
|
|
1853
|
+
+ '. The host meters only sessions the app opens through ctx.ai, so the '
|
|
1854
|
+
+ "app's own cost display may differ."
|
|
1855
|
+
: 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd) + ' — ' + capNote;
|
|
1320
1856
|
const body = [
|
|
1321
1857
|
el('div', { class: 'desc', text:
|
|
1322
1858
|
'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
|
|
@@ -1326,9 +1862,7 @@ function renderBudget(b, message) {
|
|
|
1326
1862
|
el('label', { text: 'Daily spend cap (USD)' }),
|
|
1327
1863
|
input,
|
|
1328
1864
|
]),
|
|
1329
|
-
el('div', { class: 'desc', text:
|
|
1330
|
-
? spent + ' — ' + usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
|
|
1331
|
-
: spent + ' — no cap set, spend is unlimited' }),
|
|
1865
|
+
el('div', { class: 'desc', text: spent }),
|
|
1332
1866
|
el('div', { style: 'margin-top:12px' }, [save, status]),
|
|
1333
1867
|
];
|
|
1334
1868
|
if (b.overBudget) {
|
|
@@ -1344,6 +1878,70 @@ function renderBudget(b, message) {
|
|
|
1344
1878
|
]));
|
|
1345
1879
|
}
|
|
1346
1880
|
|
|
1881
|
+
function tokens(n) {
|
|
1882
|
+
return (n ?? 0).toLocaleString('en-US');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function usageTable(headers, rows) {
|
|
1886
|
+
return el('table', {}, [
|
|
1887
|
+
el('thead', {}, [el('tr', {}, headers.map((h, i) =>
|
|
1888
|
+
el('th', i === 0 ? { text: h } : { text: h, style: 'text-align:right' })))]),
|
|
1889
|
+
el('tbody', {}, rows.map((cells) => el('tr', {}, cells.map((c, i) =>
|
|
1890
|
+
el('td', i === 0 ? { text: c } : { text: c, style: 'text-align:right' }))))),
|
|
1891
|
+
]);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function monthTokens(models) {
|
|
1895
|
+
let input = 0, output = 0;
|
|
1896
|
+
for (const t of Object.values(models ?? {})) {
|
|
1897
|
+
input += t.inputTokens ?? 0;
|
|
1898
|
+
output += t.outputTokens ?? 0;
|
|
1899
|
+
}
|
|
1900
|
+
return { input, output };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/** Read-only token/cost breakdown from GET /api/apps/:name/usage. */
|
|
1904
|
+
function renderUsage(u) {
|
|
1905
|
+
const root = document.getElementById('usage-card');
|
|
1906
|
+
root.replaceChildren();
|
|
1907
|
+
const body = [];
|
|
1908
|
+
if (!u.recorded) {
|
|
1909
|
+
body.push(el('div', { class: 'desc', text:
|
|
1910
|
+
'No AI usage recorded for this app yet. The host meters only sessions the app opens '
|
|
1911
|
+
+ 'through ctx.ai, so usage the app tracks on its own does not appear here.' }));
|
|
1912
|
+
} else {
|
|
1913
|
+
body.push(el('div', { class: 'desc', text:
|
|
1914
|
+
'Today (' + u.date + ', UTC): ' + usd(u.todayCostUsd) + ' across ' + u.todayTurns
|
|
1915
|
+
+ ' turn' + (u.todayTurns === 1 ? '' : 's') + '.' }));
|
|
1916
|
+
const models = Object.keys(u.models).sort();
|
|
1917
|
+
if (models.length > 0) {
|
|
1918
|
+
body.push(el('h3', { text: 'By model (last ' + u.retentionDays + ' days)' }));
|
|
1919
|
+
body.push(usageTable(
|
|
1920
|
+
['Model', 'Input', 'Output', 'Cache read', 'Cache write', 'Cost'],
|
|
1921
|
+
models.map((m) => {
|
|
1922
|
+
const t = u.models[m];
|
|
1923
|
+
return [m, tokens(t.inputTokens), tokens(t.outputTokens),
|
|
1924
|
+
tokens(t.cacheReadInputTokens), tokens(t.cacheCreationInputTokens), usd(t.costUsd)];
|
|
1925
|
+
}),
|
|
1926
|
+
));
|
|
1927
|
+
}
|
|
1928
|
+
if (u.months.length > 0) {
|
|
1929
|
+
body.push(el('h3', { text: 'By month' }));
|
|
1930
|
+
body.push(usageTable(
|
|
1931
|
+
['Month', 'Turns', 'Input', 'Output', 'Cost'],
|
|
1932
|
+
[...u.months].reverse().map((m) => {
|
|
1933
|
+
const t = monthTokens(m.models);
|
|
1934
|
+
return [m.month, String(m.turns), tokens(t.input), tokens(t.output), usd(m.costUsd)];
|
|
1935
|
+
}),
|
|
1936
|
+
));
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
root.append(el('div', { class: 'card' }, [
|
|
1940
|
+
el('h2', { text: 'AI usage' }),
|
|
1941
|
+
el('div', { class: 'body' }, body),
|
|
1942
|
+
]));
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1347
1945
|
function roCard(title, children) {
|
|
1348
1946
|
return el('div', { class: 'card' }, [
|
|
1349
1947
|
el('h2', { text: title }),
|
|
@@ -1417,13 +2015,15 @@ function renderManifest(m) {
|
|
|
1417
2015
|
}
|
|
1418
2016
|
|
|
1419
2017
|
async function load() {
|
|
1420
|
-
const [res, manRes, budgetRes] = await Promise.all([
|
|
2018
|
+
const [res, manRes, budgetRes, usageRes] = await Promise.all([
|
|
1421
2019
|
fetch('/api/apps/' + APP + '/config'),
|
|
1422
2020
|
fetch('/api/apps/' + APP + '/manifest'),
|
|
1423
2021
|
fetch('/api/apps/' + APP + '/budget'),
|
|
2022
|
+
fetch('/api/apps/' + APP + '/usage'),
|
|
1424
2023
|
]);
|
|
1425
|
-
// Host bookkeeping: the cap
|
|
2024
|
+
// Host bookkeeping: the cap and usage render even when the app itself fails to load.
|
|
1426
2025
|
if (budgetRes.ok) renderBudget(await budgetRes.json());
|
|
2026
|
+
if (usageRes.ok) renderUsage(await usageRes.json());
|
|
1427
2027
|
if (!res.ok) {
|
|
1428
2028
|
document.getElementById('banner').hidden = false;
|
|
1429
2029
|
document.getElementById('banner').className = 'banner warn';
|
|
@@ -1475,6 +2075,18 @@ const CONTENT_TYPES = {
|
|
|
1475
2075
|
function escapeHtml(value) {
|
|
1476
2076
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
1477
2077
|
}
|
|
2078
|
+
/**
|
|
2079
|
+
* What the dashboard's Today column actually measures. The host ledger
|
|
2080
|
+
* (`apps/<name>/usage.json`) only ever grows when a session opened through
|
|
2081
|
+
* `ctx.ai` reports `costUsd`, so an app that spends through its own bookkeeping
|
|
2082
|
+
* can legitimately show a different number in its own UI. Saying so in the
|
|
2083
|
+
* header keeps a smaller host figure from reading as a bug.
|
|
2084
|
+
*/
|
|
2085
|
+
const TODAY_COLUMN_TITLE = "AI spend today (UTC) as metered by the host: only sessions opened through an app's ctx.ai are counted, so an app's own in-app cost display may differ.";
|
|
2086
|
+
/** Tooltip for the em dash that stands in for "no host ledger at all". */
|
|
2087
|
+
const NO_SPEND_RECORDED_TITLE = "No AI spend recorded for this app yet — the host meters only sessions an app opens through ctx.ai";
|
|
2088
|
+
/** The same absence, said for the whole table rather than one app. */
|
|
2089
|
+
const NO_SPEND_RECORDED_ANY_TITLE = "No AI spend recorded for any installed app yet — the host meters only sessions an app opens through ctx.ai";
|
|
1478
2090
|
/** Host-rendered failure page — apps never need to ship their own error UI. */
|
|
1479
2091
|
function appErrorPage(name, err) {
|
|
1480
2092
|
return `<!doctype html>
|
|
@@ -1499,14 +2111,27 @@ function appErrorPage(name, err) {
|
|
|
1499
2111
|
`;
|
|
1500
2112
|
}
|
|
1501
2113
|
/**
|
|
1502
|
-
* The host HTTP surface
|
|
2114
|
+
* The host HTTP surface (all routes sit behind the auth policy middleware —
|
|
2115
|
+
* see auth.ts: public/user/owner levels, config-driven via config.auth):
|
|
2116
|
+
* GET /login sign-in form; ?token= redeems a printed login URL
|
|
2117
|
+
* POST /login { token } form -> session cookie
|
|
2118
|
+
* GET /logout destroy the session
|
|
2119
|
+
* GET /api/me the request's resolved identity, or null
|
|
1503
2120
|
* GET / dashboard of installed apps
|
|
1504
2121
|
* GET /settings/:name per-app settings page (config form)
|
|
1505
|
-
* GET /api/apps installed apps as JSON, each with todayCostUsd
|
|
1506
|
-
*
|
|
2122
|
+
* GET /api/apps installed apps as JSON, each with todayCostUsd,
|
|
2123
|
+
* `recorded` (false when the host has never metered
|
|
2124
|
+
* the app, so todayCostUsd is an absence rather than
|
|
2125
|
+
* a measured zero), and its daily spend cap /
|
|
2126
|
+
* over-budget state
|
|
2127
|
+
* GET /api/usage host-wide usage: per-app summaries (cost, turns,
|
|
2128
|
+
* per-model tokens, daily series, monthly rollups)
|
|
2129
|
+
* plus combined totals across apps
|
|
1507
2130
|
* GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
2131
|
+
* per-model token totals, monthly rollups,
|
|
2132
|
+
* `recorded`, and the budget status
|
|
2133
|
+
* GET /api/apps/:name/budget today's spend against the app's daily cap,
|
|
2134
|
+
* plus `recorded`
|
|
1510
2135
|
* PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
|
|
1511
2136
|
* (a host setting; apps cannot set their own)
|
|
1512
2137
|
* POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
|
|
@@ -1532,7 +2157,8 @@ function appErrorPage(name, err) {
|
|
|
1532
2157
|
* otherwise the app's fetch handler (/app/:name stripped),
|
|
1533
2158
|
* otherwise 404
|
|
1534
2159
|
*/
|
|
1535
|
-
function createServer(store, provider) {
|
|
2160
|
+
function createServer(store, provider, options = {}) {
|
|
2161
|
+
const auth = options.auth ?? new AuthService(store);
|
|
1536
2162
|
const apps = /* @__PURE__ */ new Map();
|
|
1537
2163
|
const sessions = /* @__PURE__ */ new Map();
|
|
1538
2164
|
const usage = new UsageRecorder(store);
|
|
@@ -1621,6 +2247,15 @@ function createServer(store, provider) {
|
|
|
1621
2247
|
}
|
|
1622
2248
|
const app = new Hono();
|
|
1623
2249
|
app.onError((err, c) => c.json({ error: String(err) }, 500));
|
|
2250
|
+
const getRemoteAddress = options.getRemoteAddress ?? ((c) => {
|
|
2251
|
+
try {
|
|
2252
|
+
return getConnInfo(c).remote.address;
|
|
2253
|
+
} catch {
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
app.use("*", createAuthMiddleware(store, auth, { getRemoteAddress }));
|
|
2258
|
+
registerAuthRoutes(app, auth);
|
|
1624
2259
|
const assetsDir = fileURLToPath(new URL("../assets", import.meta.url));
|
|
1625
2260
|
app.get("/assets/*", async (c) => {
|
|
1626
2261
|
let rest;
|
|
@@ -1662,29 +2297,31 @@ function createServer(store, provider) {
|
|
|
1662
2297
|
const status = await readiness(r.name);
|
|
1663
2298
|
const badge = !status ? "<span class=\"badge err\">failed to load</span>" : "loadError" in status ? `<span class="badge err" title="${escapeHtml(status.loadError)}">failed to load</span>` : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
|
|
1664
2299
|
const budget = await budgetStatus(store, usage, r.name);
|
|
1665
|
-
const todayCostUsd = budget.spentUsd;
|
|
1666
2300
|
const overBadge = budget.overBudget ? ` <a href="/settings/${name}" title="Daily AI spend cap reached — new sessions are refused until 00:00 UTC"><span class="badge err">over budget</span></a>` : "";
|
|
1667
2301
|
const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
|
|
2302
|
+
const today = budget.recorded ? formatUsd(budget.spentUsd) : `<span title="${escapeHtml(NO_SPEND_RECORDED_TITLE)}">—</span>`;
|
|
1668
2303
|
return {
|
|
1669
2304
|
row: `<tr>
|
|
1670
2305
|
<td><a href="/app/${name}/">${name}</a></td>
|
|
1671
2306
|
<td class="muted">v${escapeHtml(r.version)}</td>
|
|
1672
2307
|
<td class="muted">${escapeHtml(r.source.type)}</td>
|
|
1673
2308
|
<td>${badge}${overBadge}</td>
|
|
1674
|
-
<td class="muted" style="text-align:right">${
|
|
2309
|
+
<td class="muted" style="text-align:right">${today}${cap}</td>
|
|
1675
2310
|
<td style="text-align:right"><a href="/settings/${name}">settings</a></td>
|
|
1676
2311
|
</tr>`,
|
|
1677
|
-
todayCostUsd
|
|
2312
|
+
todayCostUsd: budget.spentUsd,
|
|
2313
|
+
recorded: budget.recorded
|
|
1678
2314
|
};
|
|
1679
2315
|
}));
|
|
1680
2316
|
const rows = cells.map((cell) => cell.row).join("\n");
|
|
1681
|
-
const
|
|
2317
|
+
const recorded = cells.filter((cell) => cell.recorded);
|
|
2318
|
+
const totalToday = recorded.length === 0 ? `<span title="${escapeHtml(NO_SPEND_RECORDED_ANY_TITLE)}">—</span>` : formatUsd(recorded.reduce((sum, cell) => sum + cell.todayCostUsd, 0));
|
|
1682
2319
|
const body = records.length === 0 ? "<div class=\"empty\">No apps installed. Run <code>alashi install <name></code>.</div>" : `<table>
|
|
1683
|
-
<thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="
|
|
2320
|
+
<thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="${escapeHtml(TODAY_COLUMN_TITLE)}">Today</th><th></th></tr></thead>
|
|
1684
2321
|
<tbody>
|
|
1685
2322
|
${rows}
|
|
1686
2323
|
</tbody>
|
|
1687
|
-
<tfoot><tr><td colspan="4" class="muted">AI spend today (UTC)</td><td class="muted" style="text-align:right">${
|
|
2324
|
+
<tfoot><tr><td colspan="4" class="muted" title="${escapeHtml(TODAY_COLUMN_TITLE)}">AI spend today (UTC)</td><td class="muted" style="text-align:right">${totalToday}</td><td></td></tr></tfoot>
|
|
1688
2325
|
</table>`;
|
|
1689
2326
|
return c.html(`<!doctype html>
|
|
1690
2327
|
<html lang="en">
|
|
@@ -1714,6 +2351,7 @@ ${body}
|
|
|
1714
2351
|
return {
|
|
1715
2352
|
...record,
|
|
1716
2353
|
todayCostUsd: budget.spentUsd,
|
|
2354
|
+
recorded: budget.recorded,
|
|
1717
2355
|
overBudget: budget.overBudget,
|
|
1718
2356
|
...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
|
|
1719
2357
|
};
|
|
@@ -1724,6 +2362,19 @@ ${body}
|
|
|
1724
2362
|
async function installedApp(name) {
|
|
1725
2363
|
return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
|
|
1726
2364
|
}
|
|
2365
|
+
app.get("/api/usage", async (c) => {
|
|
2366
|
+
const records = await store.listInstalled();
|
|
2367
|
+
const summaries = await Promise.all(records.map(async (record) => ({
|
|
2368
|
+
name: record.name,
|
|
2369
|
+
...await usage.summary(record.name)
|
|
2370
|
+
})));
|
|
2371
|
+
return c.json({
|
|
2372
|
+
date: utcDay(),
|
|
2373
|
+
retentionDays: 90,
|
|
2374
|
+
apps: summaries,
|
|
2375
|
+
totals: combineSummaries(summaries)
|
|
2376
|
+
});
|
|
2377
|
+
});
|
|
1727
2378
|
app.get("/api/apps/:name/usage", async (c) => {
|
|
1728
2379
|
const name = c.req.param("name");
|
|
1729
2380
|
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
@@ -2008,6 +2659,7 @@ ${body}
|
|
|
2008
2659
|
return {
|
|
2009
2660
|
app,
|
|
2010
2661
|
scheduler,
|
|
2662
|
+
auth,
|
|
2011
2663
|
async close() {
|
|
2012
2664
|
scheduler.stop();
|
|
2013
2665
|
const open = [...sessions.values()];
|
|
@@ -2123,11 +2775,28 @@ var Store = class {
|
|
|
2123
2775
|
await writeJson(this.appJobStatePath(name), state);
|
|
2124
2776
|
}
|
|
2125
2777
|
async readUsageState(name) {
|
|
2126
|
-
|
|
2778
|
+
const state = await readJson(this.appUsagePath(name));
|
|
2779
|
+
return {
|
|
2780
|
+
days: state?.days ?? [],
|
|
2781
|
+
...state?.months !== void 0 && { months: state.months }
|
|
2782
|
+
};
|
|
2127
2783
|
}
|
|
2128
2784
|
async writeUsageState(name, state) {
|
|
2129
2785
|
await writeJson(this.appUsagePath(name), state);
|
|
2130
2786
|
}
|
|
2787
|
+
get authStatePath() {
|
|
2788
|
+
return join(this.root, "auth.json");
|
|
2789
|
+
}
|
|
2790
|
+
async readAuthState() {
|
|
2791
|
+
const state = await readJson(this.authStatePath);
|
|
2792
|
+
return {
|
|
2793
|
+
tokens: state?.tokens ?? [],
|
|
2794
|
+
sessions: state?.sessions ?? []
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
async writeAuthState(state) {
|
|
2798
|
+
await writeJson(this.authStatePath, state);
|
|
2799
|
+
}
|
|
2131
2800
|
async listInstalled() {
|
|
2132
2801
|
let entries;
|
|
2133
2802
|
try {
|
|
@@ -2146,6 +2815,6 @@ var Store = class {
|
|
|
2146
2815
|
}
|
|
2147
2816
|
};
|
|
2148
2817
|
//#endregion
|
|
2149
|
-
export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, utcDay as M, setAppBudget as O, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
|
|
2818
|
+
export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
|
|
2150
2819
|
|
|
2151
|
-
//# sourceMappingURL=store-
|
|
2820
|
+
//# sourceMappingURL=store-BNZqhcwm.mjs.map
|