@alashi/cli 0.11.0 → 0.13.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 +145 -11
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +313 -29
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{store-VoJe_U1Q.mjs → store-DoAGOynU.mjs} +745 -70
- package/dist/store-DoAGOynU.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,100 @@ 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
|
+
models[model] ??= emptyTotals();
|
|
423
|
+
const totals = models[model];
|
|
424
|
+
totals.inputTokens += count(turn.inputTokens);
|
|
425
|
+
totals.outputTokens += count(turn.outputTokens);
|
|
426
|
+
totals.cacheReadInputTokens += count(turn.cacheReadInputTokens);
|
|
427
|
+
totals.cacheCreationInputTokens += count(turn.cacheCreationInputTokens);
|
|
428
|
+
totals.costUsd = round(totals.costUsd + count(turn.costUsd));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function mergeTotals(into, from) {
|
|
432
|
+
for (const [model, totals] of Object.entries(from ?? {})) {
|
|
433
|
+
into[model] ??= emptyTotals();
|
|
434
|
+
const target = into[model];
|
|
435
|
+
target.inputTokens += count(totals.inputTokens);
|
|
436
|
+
target.outputTokens += count(totals.outputTokens);
|
|
437
|
+
target.cacheReadInputTokens += count(totals.cacheReadInputTokens);
|
|
438
|
+
target.cacheCreationInputTokens += count(totals.cacheCreationInputTokens);
|
|
439
|
+
target.costUsd = round(target.costUsd + count(totals.costUsd));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function sanitizeModels(models) {
|
|
443
|
+
if (models === void 0) return void 0;
|
|
444
|
+
const clean = {};
|
|
445
|
+
for (const [model, totals] of Object.entries(models)) clean[model] = {
|
|
446
|
+
inputTokens: count(totals?.inputTokens),
|
|
447
|
+
outputTokens: count(totals?.outputTokens),
|
|
448
|
+
cacheReadInputTokens: count(totals?.cacheReadInputTokens),
|
|
449
|
+
cacheCreationInputTokens: count(totals?.cacheCreationInputTokens),
|
|
450
|
+
costUsd: round(count(totals?.costUsd))
|
|
451
|
+
};
|
|
452
|
+
return Object.keys(clean).length > 0 ? clean : void 0;
|
|
453
|
+
}
|
|
31
454
|
/**
|
|
32
|
-
* Per-app
|
|
33
|
-
* container (`apps/<name>/usage.json`, next to jobs.json and chat.json)
|
|
455
|
+
* Per-app AI spend and token usage, persisted as OS bookkeeping in the app
|
|
456
|
+
* container (`apps/<name>/usage.json`, next to jobs.json and chat.json):
|
|
457
|
+
* a per-UTC-day series trimmed to the retention window, plus per-month
|
|
458
|
+
* rollups that are kept forever.
|
|
34
459
|
*
|
|
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.
|
|
460
|
+
* Cost and per-model tokens arrive from the `result` event, whose fields the
|
|
461
|
+
* SDK contract marks optional: engines that report neither simply contribute
|
|
462
|
+
* nothing. Writes are serialized per app so concurrent turns cannot lose an
|
|
463
|
+
* update, and each app has its own queue so a slow write for one app never
|
|
464
|
+
* delays another.
|
|
40
465
|
*/
|
|
41
466
|
var UsageRecorder = class {
|
|
42
467
|
store;
|
|
@@ -48,53 +473,136 @@ var UsageRecorder = class {
|
|
|
48
473
|
this.retentionDays = retentionDays;
|
|
49
474
|
}
|
|
50
475
|
/**
|
|
51
|
-
* Adds one turn's cost to the app's
|
|
52
|
-
* A missing, non-finite, or negative `costUsd` is
|
|
53
|
-
*
|
|
476
|
+
* Adds one turn's cost and token usage to the app's totals for the UTC day
|
|
477
|
+
* and month of `at`. A missing, non-finite, or negative `costUsd` is
|
|
478
|
+
* ignored; a turn that reports neither valid cost nor any model usage is
|
|
479
|
+
* not counted and nothing is written, so a provider that omits metering
|
|
480
|
+
* cannot corrupt the totals.
|
|
54
481
|
*/
|
|
55
|
-
record(appName,
|
|
56
|
-
|
|
482
|
+
record(appName, turn, at = /* @__PURE__ */ new Date()) {
|
|
483
|
+
const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
|
|
484
|
+
const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
|
|
485
|
+
if (!costValid && modelUsage === void 0) return Promise.resolve();
|
|
486
|
+
const costUsd = costValid ? turn.costUsd : 0;
|
|
57
487
|
return this.enqueue(appName, async () => {
|
|
58
|
-
const state = await this.store.readUsageState(appName);
|
|
488
|
+
const state = this.normalize(await this.store.readUsageState(appName));
|
|
59
489
|
const date = utcDay(at);
|
|
60
490
|
const day = state.days.find((entry) => entry.date === date);
|
|
61
|
-
if (day)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
491
|
+
if (day) this.apply(day, costUsd, modelUsage);
|
|
492
|
+
else {
|
|
493
|
+
const fresh = {
|
|
494
|
+
date,
|
|
495
|
+
costUsd: 0,
|
|
496
|
+
turns: 0
|
|
497
|
+
};
|
|
498
|
+
this.apply(fresh, costUsd, modelUsage);
|
|
499
|
+
state.days.push(fresh);
|
|
500
|
+
}
|
|
501
|
+
const monthKey = utcMonth(at);
|
|
502
|
+
const months = state.months ?? [];
|
|
503
|
+
const month = months.find((entry) => entry.month === monthKey);
|
|
504
|
+
if (month) this.apply(month, costUsd, modelUsage);
|
|
505
|
+
else {
|
|
506
|
+
const fresh = {
|
|
507
|
+
month: monthKey,
|
|
508
|
+
costUsd: 0,
|
|
509
|
+
turns: 0
|
|
510
|
+
};
|
|
511
|
+
this.apply(fresh, costUsd, modelUsage);
|
|
512
|
+
months.push(fresh);
|
|
513
|
+
}
|
|
514
|
+
state.months = months;
|
|
69
515
|
await this.store.writeUsageState(appName, this.prune(state, at));
|
|
70
516
|
});
|
|
71
517
|
}
|
|
72
|
-
/** The app's recorded
|
|
518
|
+
/** The app's recorded usage, oldest first, with expired days filtered out. */
|
|
73
519
|
async read(appName, at = /* @__PURE__ */ new Date()) {
|
|
74
|
-
return this.prune(await this.store.readUsageState(appName), at);
|
|
520
|
+
return this.prune(this.normalize(await this.store.readUsageState(appName)), at);
|
|
75
521
|
}
|
|
76
|
-
/** Today's spend plus the daily series, for the dashboard and the usage
|
|
522
|
+
/** Today's spend plus the daily/monthly series, for the dashboard and the usage endpoints. */
|
|
77
523
|
async summary(appName, at = /* @__PURE__ */ new Date()) {
|
|
78
|
-
const { days } = await this.read(appName, at);
|
|
524
|
+
const { days, months } = await this.read(appName, at);
|
|
79
525
|
const date = utcDay(at);
|
|
80
526
|
const today = days.find((entry) => entry.date === date);
|
|
527
|
+
const models = {};
|
|
528
|
+
for (const day of days) mergeTotals(models, day.models);
|
|
81
529
|
return {
|
|
82
530
|
date,
|
|
83
531
|
todayCostUsd: today?.costUsd ?? 0,
|
|
84
532
|
todayTurns: today?.turns ?? 0,
|
|
533
|
+
todayModels: today?.models ?? {},
|
|
85
534
|
totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
|
|
535
|
+
models,
|
|
86
536
|
retentionDays: this.retentionDays,
|
|
87
|
-
days
|
|
537
|
+
recorded: days.length > 0 || months.length > 0,
|
|
538
|
+
days,
|
|
539
|
+
months
|
|
88
540
|
};
|
|
89
541
|
}
|
|
90
|
-
|
|
542
|
+
apply(bucket, costUsd, modelUsage) {
|
|
543
|
+
bucket.costUsd = round(bucket.costUsd + costUsd);
|
|
544
|
+
bucket.turns += 1;
|
|
545
|
+
if (modelUsage !== void 0) {
|
|
546
|
+
bucket.models ??= {};
|
|
547
|
+
addModelUsage(bucket.models, modelUsage);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* One-time migration for files written before monthly rollups existed:
|
|
552
|
+
* absent `months` is seeded from the surviving days, so history inside the
|
|
553
|
+
* retention window is not lost to the rollups. After the first write the
|
|
554
|
+
* field is always present (possibly empty) and this is a no-op.
|
|
555
|
+
*/
|
|
556
|
+
normalize(state) {
|
|
557
|
+
if (state.months !== void 0) return {
|
|
558
|
+
days: state.days,
|
|
559
|
+
months: state.months
|
|
560
|
+
};
|
|
561
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
562
|
+
for (const day of state.days) {
|
|
563
|
+
if (typeof day?.date !== "string" || day.date.length < 7) continue;
|
|
564
|
+
const key = day.date.slice(0, 7);
|
|
565
|
+
const month = byMonth.get(key) ?? {
|
|
566
|
+
month: key,
|
|
567
|
+
costUsd: 0,
|
|
568
|
+
turns: 0
|
|
569
|
+
};
|
|
570
|
+
month.costUsd = round(month.costUsd + (Number.isFinite(day.costUsd) ? day.costUsd : 0));
|
|
571
|
+
month.turns += Number.isFinite(day.turns) ? day.turns : 0;
|
|
572
|
+
if (day.models !== void 0) {
|
|
573
|
+
month.models ??= {};
|
|
574
|
+
mergeTotals(month.models, day.models);
|
|
575
|
+
}
|
|
576
|
+
byMonth.set(key, month);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
days: state.days,
|
|
580
|
+
months: [...byMonth.values()]
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
|
|
91
584
|
prune(state, at) {
|
|
92
585
|
const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
|
|
93
|
-
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
586
|
+
return {
|
|
587
|
+
days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
588
|
+
const models = sanitizeModels(entry.models);
|
|
589
|
+
return {
|
|
590
|
+
date: entry.date,
|
|
591
|
+
costUsd: round(entry.costUsd),
|
|
592
|
+
turns: Number.isFinite(entry.turns) ? entry.turns : 0,
|
|
593
|
+
...models !== void 0 && { models }
|
|
594
|
+
};
|
|
595
|
+
}).sort((a, b) => a.date.localeCompare(b.date)),
|
|
596
|
+
months: state.months.filter((entry) => typeof entry?.month === "string" && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
597
|
+
const models = sanitizeModels(entry.models);
|
|
598
|
+
return {
|
|
599
|
+
month: entry.month,
|
|
600
|
+
costUsd: round(entry.costUsd),
|
|
601
|
+
turns: Number.isFinite(entry.turns) ? entry.turns : 0,
|
|
602
|
+
...models !== void 0 && { models }
|
|
603
|
+
};
|
|
604
|
+
}).sort((a, b) => a.month.localeCompare(b.month))
|
|
605
|
+
};
|
|
98
606
|
}
|
|
99
607
|
enqueue(appName, work) {
|
|
100
608
|
const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
|
|
@@ -170,6 +678,7 @@ function budgetStatusFor(summary, dailyUsd) {
|
|
|
170
678
|
return {
|
|
171
679
|
date: summary.date,
|
|
172
680
|
spentUsd: summary.todayCostUsd,
|
|
681
|
+
recorded: summary.recorded,
|
|
173
682
|
...dailyUsd !== void 0 && {
|
|
174
683
|
dailyUsd,
|
|
175
684
|
remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
|
|
@@ -226,6 +735,9 @@ function checkSdkCompatibility(manifest) {
|
|
|
226
735
|
}
|
|
227
736
|
//#endregion
|
|
228
737
|
//#region src/loader.ts
|
|
738
|
+
function isRecord(value) {
|
|
739
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
740
|
+
}
|
|
229
741
|
function entryFromExports(exportsField) {
|
|
230
742
|
if (typeof exportsField === "string") return exportsField;
|
|
231
743
|
if (typeof exportsField !== "object" || exportsField === null) return void 0;
|
|
@@ -243,7 +755,7 @@ function entryFromExports(exportsField) {
|
|
|
243
755
|
* The manifest is always re-validated here — the host never trusts
|
|
244
756
|
* that an app used defineApp().
|
|
245
757
|
*/
|
|
246
|
-
async function loadApp(repoDir) {
|
|
758
|
+
async function loadApp(repoDir, options = {}) {
|
|
247
759
|
let pkg;
|
|
248
760
|
try {
|
|
249
761
|
pkg = JSON.parse(await readFile(join(repoDir, "package.json"), "utf8"));
|
|
@@ -262,15 +774,21 @@ async function loadApp(repoDir) {
|
|
|
262
774
|
}
|
|
263
775
|
const definition = mod.default;
|
|
264
776
|
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
|
|
777
|
+
const input = isRecord(definition.rawManifest) ? definition.rawManifest : definition.manifest;
|
|
778
|
+
const raw = input;
|
|
266
779
|
if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
|
|
267
780
|
name: typeof raw.name === "string" ? raw.name : entry,
|
|
268
781
|
sdkVersion: raw.sdkVersion
|
|
269
782
|
});
|
|
783
|
+
const { manifest, unknownKeys } = parseManifestWithWarnings(input);
|
|
784
|
+
const warnings = unknownKeys.map((key) => `ignoring unknown manifest key "${key}" — this host uses SDK ${SDK_VERSION}`);
|
|
785
|
+
const warn = options.warn ?? ((message) => console.warn(`[${manifest.name}] ${message}`));
|
|
786
|
+
for (const warning of warnings) warn(warning);
|
|
270
787
|
return {
|
|
271
788
|
definition,
|
|
272
|
-
manifest
|
|
273
|
-
entryPath
|
|
789
|
+
manifest,
|
|
790
|
+
entryPath,
|
|
791
|
+
warnings
|
|
274
792
|
};
|
|
275
793
|
}
|
|
276
794
|
//#endregion
|
|
@@ -481,7 +999,7 @@ async function installApp(store, source, options = {}) {
|
|
|
481
999
|
inspectDir = stageDir;
|
|
482
1000
|
}
|
|
483
1001
|
try {
|
|
484
|
-
const { manifest } = await loadApp(inspectDir);
|
|
1002
|
+
const { manifest, warnings } = await loadApp(inspectDir, { warn: () => {} });
|
|
485
1003
|
if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
|
|
486
1004
|
if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
|
|
487
1005
|
const repoDir = store.appRepoDir(manifest.name);
|
|
@@ -501,7 +1019,10 @@ async function installApp(store, source, options = {}) {
|
|
|
501
1019
|
updatedAt: now
|
|
502
1020
|
};
|
|
503
1021
|
await store.writeAppRecord(record);
|
|
504
|
-
return
|
|
1022
|
+
return {
|
|
1023
|
+
record,
|
|
1024
|
+
warnings
|
|
1025
|
+
};
|
|
505
1026
|
} finally {
|
|
506
1027
|
if (stageDir) await rm(stageDir, {
|
|
507
1028
|
recursive: true,
|
|
@@ -515,7 +1036,7 @@ async function updateApp(store, name) {
|
|
|
515
1036
|
const repoDir = store.appRepoDir(name);
|
|
516
1037
|
await updateSource(record.source, repoDir);
|
|
517
1038
|
await installDependencies(repoDir);
|
|
518
|
-
const { manifest } = await loadApp(repoDir);
|
|
1039
|
+
const { manifest, warnings } = await loadApp(repoDir, { warn: () => {} });
|
|
519
1040
|
if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
|
|
520
1041
|
const updated = {
|
|
521
1042
|
...record,
|
|
@@ -524,7 +1045,10 @@ async function updateApp(store, name) {
|
|
|
524
1045
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
525
1046
|
};
|
|
526
1047
|
await store.writeAppRecord(updated);
|
|
527
|
-
return
|
|
1048
|
+
return {
|
|
1049
|
+
record: updated,
|
|
1050
|
+
warnings
|
|
1051
|
+
};
|
|
528
1052
|
}
|
|
529
1053
|
async function removeApp(store, name, options = {}) {
|
|
530
1054
|
if (!await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is not installed`);
|
|
@@ -832,7 +1356,7 @@ function createLogger(prefix) {
|
|
|
832
1356
|
* chat turns, cron job handlers, and app routes alike — so spend is recorded
|
|
833
1357
|
* no matter who opened the session.
|
|
834
1358
|
*/
|
|
835
|
-
function meterSession(session,
|
|
1359
|
+
function meterSession(session, recordUsage) {
|
|
836
1360
|
return {
|
|
837
1361
|
get id() {
|
|
838
1362
|
return session.id;
|
|
@@ -841,7 +1365,10 @@ function meterSession(session, recordCost) {
|
|
|
841
1365
|
const events = session.send(input);
|
|
842
1366
|
return (async function* metered() {
|
|
843
1367
|
for await (const event of events) {
|
|
844
|
-
if (event.type === "result") await
|
|
1368
|
+
if (event.type === "result") await recordUsage({
|
|
1369
|
+
costUsd: event.costUsd,
|
|
1370
|
+
modelUsage: event.modelUsage
|
|
1371
|
+
});
|
|
845
1372
|
yield event;
|
|
846
1373
|
}
|
|
847
1374
|
})();
|
|
@@ -881,7 +1408,7 @@ function scopeProvider(provider, defaults) {
|
|
|
881
1408
|
...Object.keys(agents).length > 0 && { agents },
|
|
882
1409
|
...skillDirs.length > 0 && { skillDirs }
|
|
883
1410
|
});
|
|
884
|
-
return defaults.
|
|
1411
|
+
return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
|
|
885
1412
|
}
|
|
886
1413
|
};
|
|
887
1414
|
}
|
|
@@ -943,11 +1470,11 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
943
1470
|
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
944
1471
|
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
945
1472
|
const logger = createLogger(name);
|
|
946
|
-
const
|
|
1473
|
+
const recordUsage = usage ? async (turn) => {
|
|
947
1474
|
try {
|
|
948
|
-
await usage.record(name,
|
|
1475
|
+
await usage.record(name, turn);
|
|
949
1476
|
} catch (err) {
|
|
950
|
-
logger.warn("Failed to record AI
|
|
1477
|
+
logger.warn("Failed to record AI usage", { error: String(err) });
|
|
951
1478
|
}
|
|
952
1479
|
} : void 0;
|
|
953
1480
|
return {
|
|
@@ -963,7 +1490,7 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
963
1490
|
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
964
1491
|
...instructions !== void 0 && { instructions },
|
|
965
1492
|
cwd: dataDir,
|
|
966
|
-
...
|
|
1493
|
+
...recordUsage !== void 0 && { recordUsage },
|
|
967
1494
|
...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
|
|
968
1495
|
...!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
1496
|
})
|
|
@@ -1034,6 +1561,10 @@ var Scheduler = class {
|
|
|
1034
1561
|
this.crons = [];
|
|
1035
1562
|
this.started = false;
|
|
1036
1563
|
}
|
|
1564
|
+
/** How many jobs are currently scheduled (0 until start()). */
|
|
1565
|
+
get jobCount() {
|
|
1566
|
+
return this.crons.length;
|
|
1567
|
+
}
|
|
1037
1568
|
/**
|
|
1038
1569
|
* Runs one job immediately. Returns the recorded run, 'skipped' when that
|
|
1039
1570
|
* job is already mid-run, or undefined when the app/job doesn't exist.
|
|
@@ -1140,6 +1671,7 @@ function settingsPage(name) {
|
|
|
1140
1671
|
<main class="narrow">
|
|
1141
1672
|
<div id="banner" class="banner" hidden></div>
|
|
1142
1673
|
<div id="budget-card"></div>
|
|
1674
|
+
<div id="usage-card"></div>
|
|
1143
1675
|
<div id="cards"></div>
|
|
1144
1676
|
<button id="save" class="primary">Save changes</button><span id="status"></span>
|
|
1145
1677
|
<div id="manifest-cards"></div>
|
|
@@ -1316,7 +1848,17 @@ function renderBudget(b, message) {
|
|
|
1316
1848
|
},
|
|
1317
1849
|
});
|
|
1318
1850
|
|
|
1319
|
-
const
|
|
1851
|
+
const capNote = capped
|
|
1852
|
+
? usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
|
|
1853
|
+
: 'no cap set, spend is unlimited';
|
|
1854
|
+
// No ledger at all is an absence, not $0.0000: the host meters only sessions
|
|
1855
|
+
// opened through ctx.ai, so an app that tracks its own costs elsewhere can
|
|
1856
|
+
// report real spend while the host has recorded nothing for it.
|
|
1857
|
+
const spent = b.recorded === false
|
|
1858
|
+
? 'No AI spend recorded for this app yet (' + b.date + ', UTC) — ' + capNote
|
|
1859
|
+
+ '. The host meters only sessions the app opens through ctx.ai, so the '
|
|
1860
|
+
+ "app's own cost display may differ."
|
|
1861
|
+
: 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd) + ' — ' + capNote;
|
|
1320
1862
|
const body = [
|
|
1321
1863
|
el('div', { class: 'desc', text:
|
|
1322
1864
|
'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
|
|
@@ -1326,9 +1868,7 @@ function renderBudget(b, message) {
|
|
|
1326
1868
|
el('label', { text: 'Daily spend cap (USD)' }),
|
|
1327
1869
|
input,
|
|
1328
1870
|
]),
|
|
1329
|
-
el('div', { class: 'desc', text:
|
|
1330
|
-
? spent + ' — ' + usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
|
|
1331
|
-
: spent + ' — no cap set, spend is unlimited' }),
|
|
1871
|
+
el('div', { class: 'desc', text: spent }),
|
|
1332
1872
|
el('div', { style: 'margin-top:12px' }, [save, status]),
|
|
1333
1873
|
];
|
|
1334
1874
|
if (b.overBudget) {
|
|
@@ -1344,6 +1884,70 @@ function renderBudget(b, message) {
|
|
|
1344
1884
|
]));
|
|
1345
1885
|
}
|
|
1346
1886
|
|
|
1887
|
+
function tokens(n) {
|
|
1888
|
+
return (n ?? 0).toLocaleString('en-US');
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function usageTable(headers, rows) {
|
|
1892
|
+
return el('table', {}, [
|
|
1893
|
+
el('thead', {}, [el('tr', {}, headers.map((h, i) =>
|
|
1894
|
+
el('th', i === 0 ? { text: h } : { text: h, style: 'text-align:right' })))]),
|
|
1895
|
+
el('tbody', {}, rows.map((cells) => el('tr', {}, cells.map((c, i) =>
|
|
1896
|
+
el('td', i === 0 ? { text: c } : { text: c, style: 'text-align:right' }))))),
|
|
1897
|
+
]);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function monthTokens(models) {
|
|
1901
|
+
let input = 0, output = 0;
|
|
1902
|
+
for (const t of Object.values(models ?? {})) {
|
|
1903
|
+
input += t.inputTokens ?? 0;
|
|
1904
|
+
output += t.outputTokens ?? 0;
|
|
1905
|
+
}
|
|
1906
|
+
return { input, output };
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/** Read-only token/cost breakdown from GET /api/apps/:name/usage. */
|
|
1910
|
+
function renderUsage(u) {
|
|
1911
|
+
const root = document.getElementById('usage-card');
|
|
1912
|
+
root.replaceChildren();
|
|
1913
|
+
const body = [];
|
|
1914
|
+
if (!u.recorded) {
|
|
1915
|
+
body.push(el('div', { class: 'desc', text:
|
|
1916
|
+
'No AI usage recorded for this app yet. The host meters only sessions the app opens '
|
|
1917
|
+
+ 'through ctx.ai, so usage the app tracks on its own does not appear here.' }));
|
|
1918
|
+
} else {
|
|
1919
|
+
body.push(el('div', { class: 'desc', text:
|
|
1920
|
+
'Today (' + u.date + ', UTC): ' + usd(u.todayCostUsd) + ' across ' + u.todayTurns
|
|
1921
|
+
+ ' turn' + (u.todayTurns === 1 ? '' : 's') + '.' }));
|
|
1922
|
+
const models = Object.keys(u.models).sort();
|
|
1923
|
+
if (models.length > 0) {
|
|
1924
|
+
body.push(el('h3', { text: 'By model (last ' + u.retentionDays + ' days)' }));
|
|
1925
|
+
body.push(usageTable(
|
|
1926
|
+
['Model', 'Input', 'Output', 'Cache read', 'Cache write', 'Cost'],
|
|
1927
|
+
models.map((m) => {
|
|
1928
|
+
const t = u.models[m];
|
|
1929
|
+
return [m, tokens(t.inputTokens), tokens(t.outputTokens),
|
|
1930
|
+
tokens(t.cacheReadInputTokens), tokens(t.cacheCreationInputTokens), usd(t.costUsd)];
|
|
1931
|
+
}),
|
|
1932
|
+
));
|
|
1933
|
+
}
|
|
1934
|
+
if (u.months.length > 0) {
|
|
1935
|
+
body.push(el('h3', { text: 'By month' }));
|
|
1936
|
+
body.push(usageTable(
|
|
1937
|
+
['Month', 'Turns', 'Input', 'Output', 'Cost'],
|
|
1938
|
+
[...u.months].reverse().map((m) => {
|
|
1939
|
+
const t = monthTokens(m.models);
|
|
1940
|
+
return [m.month, String(m.turns), tokens(t.input), tokens(t.output), usd(m.costUsd)];
|
|
1941
|
+
}),
|
|
1942
|
+
));
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
root.append(el('div', { class: 'card' }, [
|
|
1946
|
+
el('h2', { text: 'AI usage' }),
|
|
1947
|
+
el('div', { class: 'body' }, body),
|
|
1948
|
+
]));
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1347
1951
|
function roCard(title, children) {
|
|
1348
1952
|
return el('div', { class: 'card' }, [
|
|
1349
1953
|
el('h2', { text: title }),
|
|
@@ -1417,13 +2021,15 @@ function renderManifest(m) {
|
|
|
1417
2021
|
}
|
|
1418
2022
|
|
|
1419
2023
|
async function load() {
|
|
1420
|
-
const [res, manRes, budgetRes] = await Promise.all([
|
|
2024
|
+
const [res, manRes, budgetRes, usageRes] = await Promise.all([
|
|
1421
2025
|
fetch('/api/apps/' + APP + '/config'),
|
|
1422
2026
|
fetch('/api/apps/' + APP + '/manifest'),
|
|
1423
2027
|
fetch('/api/apps/' + APP + '/budget'),
|
|
2028
|
+
fetch('/api/apps/' + APP + '/usage'),
|
|
1424
2029
|
]);
|
|
1425
|
-
// Host bookkeeping: the cap
|
|
2030
|
+
// Host bookkeeping: the cap and usage render even when the app itself fails to load.
|
|
1426
2031
|
if (budgetRes.ok) renderBudget(await budgetRes.json());
|
|
2032
|
+
if (usageRes.ok) renderUsage(await usageRes.json());
|
|
1427
2033
|
if (!res.ok) {
|
|
1428
2034
|
document.getElementById('banner').hidden = false;
|
|
1429
2035
|
document.getElementById('banner').className = 'banner warn';
|
|
@@ -1475,6 +2081,18 @@ const CONTENT_TYPES = {
|
|
|
1475
2081
|
function escapeHtml(value) {
|
|
1476
2082
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
1477
2083
|
}
|
|
2084
|
+
/**
|
|
2085
|
+
* What the dashboard's Today column actually measures. The host ledger
|
|
2086
|
+
* (`apps/<name>/usage.json`) only ever grows when a session opened through
|
|
2087
|
+
* `ctx.ai` reports `costUsd`, so an app that spends through its own bookkeeping
|
|
2088
|
+
* can legitimately show a different number in its own UI. Saying so in the
|
|
2089
|
+
* header keeps a smaller host figure from reading as a bug.
|
|
2090
|
+
*/
|
|
2091
|
+
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.";
|
|
2092
|
+
/** Tooltip for the em dash that stands in for "no host ledger at all". */
|
|
2093
|
+
const NO_SPEND_RECORDED_TITLE = "No AI spend recorded for this app yet — the host meters only sessions an app opens through ctx.ai";
|
|
2094
|
+
/** The same absence, said for the whole table rather than one app. */
|
|
2095
|
+
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
2096
|
/** Host-rendered failure page — apps never need to ship their own error UI. */
|
|
1479
2097
|
function appErrorPage(name, err) {
|
|
1480
2098
|
return `<!doctype html>
|
|
@@ -1499,14 +2117,27 @@ function appErrorPage(name, err) {
|
|
|
1499
2117
|
`;
|
|
1500
2118
|
}
|
|
1501
2119
|
/**
|
|
1502
|
-
* The host HTTP surface
|
|
2120
|
+
* The host HTTP surface (all routes sit behind the auth policy middleware —
|
|
2121
|
+
* see auth.ts: public/user/owner levels, config-driven via config.auth):
|
|
2122
|
+
* GET /login sign-in form; ?token= redeems a printed login URL
|
|
2123
|
+
* POST /login { token } form -> session cookie
|
|
2124
|
+
* GET /logout destroy the session
|
|
2125
|
+
* GET /api/me the request's resolved identity, or null
|
|
1503
2126
|
* GET / dashboard of installed apps
|
|
1504
2127
|
* GET /settings/:name per-app settings page (config form)
|
|
1505
|
-
* GET /api/apps installed apps as JSON, each with todayCostUsd
|
|
1506
|
-
*
|
|
2128
|
+
* GET /api/apps installed apps as JSON, each with todayCostUsd,
|
|
2129
|
+
* `recorded` (false when the host has never metered
|
|
2130
|
+
* the app, so todayCostUsd is an absence rather than
|
|
2131
|
+
* a measured zero), and its daily spend cap /
|
|
2132
|
+
* over-budget state
|
|
2133
|
+
* GET /api/usage host-wide usage: per-app summaries (cost, turns,
|
|
2134
|
+
* per-model tokens, daily series, monthly rollups)
|
|
2135
|
+
* plus combined totals across apps
|
|
1507
2136
|
* GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
2137
|
+
* per-model token totals, monthly rollups,
|
|
2138
|
+
* `recorded`, and the budget status
|
|
2139
|
+
* GET /api/apps/:name/budget today's spend against the app's daily cap,
|
|
2140
|
+
* plus `recorded`
|
|
1510
2141
|
* PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
|
|
1511
2142
|
* (a host setting; apps cannot set their own)
|
|
1512
2143
|
* POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
|
|
@@ -1532,7 +2163,8 @@ function appErrorPage(name, err) {
|
|
|
1532
2163
|
* otherwise the app's fetch handler (/app/:name stripped),
|
|
1533
2164
|
* otherwise 404
|
|
1534
2165
|
*/
|
|
1535
|
-
function createServer(store, provider) {
|
|
2166
|
+
function createServer(store, provider, options = {}) {
|
|
2167
|
+
const auth = options.auth ?? new AuthService(store);
|
|
1536
2168
|
const apps = /* @__PURE__ */ new Map();
|
|
1537
2169
|
const sessions = /* @__PURE__ */ new Map();
|
|
1538
2170
|
const usage = new UsageRecorder(store);
|
|
@@ -1621,6 +2253,15 @@ function createServer(store, provider) {
|
|
|
1621
2253
|
}
|
|
1622
2254
|
const app = new Hono();
|
|
1623
2255
|
app.onError((err, c) => c.json({ error: String(err) }, 500));
|
|
2256
|
+
const getRemoteAddress = options.getRemoteAddress ?? ((c) => {
|
|
2257
|
+
try {
|
|
2258
|
+
return getConnInfo(c).remote.address;
|
|
2259
|
+
} catch {
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
app.use("*", createAuthMiddleware(store, auth, { getRemoteAddress }));
|
|
2264
|
+
registerAuthRoutes(app, auth);
|
|
1624
2265
|
const assetsDir = fileURLToPath(new URL("../assets", import.meta.url));
|
|
1625
2266
|
app.get("/assets/*", async (c) => {
|
|
1626
2267
|
let rest;
|
|
@@ -1662,29 +2303,31 @@ function createServer(store, provider) {
|
|
|
1662
2303
|
const status = await readiness(r.name);
|
|
1663
2304
|
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
2305
|
const budget = await budgetStatus(store, usage, r.name);
|
|
1665
|
-
const todayCostUsd = budget.spentUsd;
|
|
1666
2306
|
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
2307
|
const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
|
|
2308
|
+
const today = budget.recorded ? formatUsd(budget.spentUsd) : `<span title="${escapeHtml(NO_SPEND_RECORDED_TITLE)}">—</span>`;
|
|
1668
2309
|
return {
|
|
1669
2310
|
row: `<tr>
|
|
1670
2311
|
<td><a href="/app/${name}/">${name}</a></td>
|
|
1671
2312
|
<td class="muted">v${escapeHtml(r.version)}</td>
|
|
1672
2313
|
<td class="muted">${escapeHtml(r.source.type)}</td>
|
|
1673
2314
|
<td>${badge}${overBadge}</td>
|
|
1674
|
-
<td class="muted" style="text-align:right">${
|
|
2315
|
+
<td class="muted" style="text-align:right">${today}${cap}</td>
|
|
1675
2316
|
<td style="text-align:right"><a href="/settings/${name}">settings</a></td>
|
|
1676
2317
|
</tr>`,
|
|
1677
|
-
todayCostUsd
|
|
2318
|
+
todayCostUsd: budget.spentUsd,
|
|
2319
|
+
recorded: budget.recorded
|
|
1678
2320
|
};
|
|
1679
2321
|
}));
|
|
1680
2322
|
const rows = cells.map((cell) => cell.row).join("\n");
|
|
1681
|
-
const
|
|
2323
|
+
const recorded = cells.filter((cell) => cell.recorded);
|
|
2324
|
+
const totalToday = recorded.length === 0 ? `<span title="${escapeHtml(NO_SPEND_RECORDED_ANY_TITLE)}">—</span>` : formatUsd(recorded.reduce((sum, cell) => sum + cell.todayCostUsd, 0));
|
|
1682
2325
|
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="
|
|
2326
|
+
<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
2327
|
<tbody>
|
|
1685
2328
|
${rows}
|
|
1686
2329
|
</tbody>
|
|
1687
|
-
<tfoot><tr><td colspan="4" class="muted">AI spend today (UTC)</td><td class="muted" style="text-align:right">${
|
|
2330
|
+
<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
2331
|
</table>`;
|
|
1689
2332
|
return c.html(`<!doctype html>
|
|
1690
2333
|
<html lang="en">
|
|
@@ -1714,6 +2357,7 @@ ${body}
|
|
|
1714
2357
|
return {
|
|
1715
2358
|
...record,
|
|
1716
2359
|
todayCostUsd: budget.spentUsd,
|
|
2360
|
+
recorded: budget.recorded,
|
|
1717
2361
|
overBudget: budget.overBudget,
|
|
1718
2362
|
...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
|
|
1719
2363
|
};
|
|
@@ -1724,6 +2368,19 @@ ${body}
|
|
|
1724
2368
|
async function installedApp(name) {
|
|
1725
2369
|
return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
|
|
1726
2370
|
}
|
|
2371
|
+
app.get("/api/usage", async (c) => {
|
|
2372
|
+
const records = await store.listInstalled();
|
|
2373
|
+
const summaries = await Promise.all(records.map(async (record) => ({
|
|
2374
|
+
name: record.name,
|
|
2375
|
+
...await usage.summary(record.name)
|
|
2376
|
+
})));
|
|
2377
|
+
return c.json({
|
|
2378
|
+
date: utcDay(),
|
|
2379
|
+
retentionDays: 90,
|
|
2380
|
+
apps: summaries,
|
|
2381
|
+
totals: combineSummaries(summaries)
|
|
2382
|
+
});
|
|
2383
|
+
});
|
|
1727
2384
|
app.get("/api/apps/:name/usage", async (c) => {
|
|
1728
2385
|
const name = c.req.param("name");
|
|
1729
2386
|
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
@@ -2008,6 +2665,7 @@ ${body}
|
|
|
2008
2665
|
return {
|
|
2009
2666
|
app,
|
|
2010
2667
|
scheduler,
|
|
2668
|
+
auth,
|
|
2011
2669
|
async close() {
|
|
2012
2670
|
scheduler.stop();
|
|
2013
2671
|
const open = [...sessions.values()];
|
|
@@ -2123,11 +2781,28 @@ var Store = class {
|
|
|
2123
2781
|
await writeJson(this.appJobStatePath(name), state);
|
|
2124
2782
|
}
|
|
2125
2783
|
async readUsageState(name) {
|
|
2126
|
-
|
|
2784
|
+
const state = await readJson(this.appUsagePath(name));
|
|
2785
|
+
return {
|
|
2786
|
+
days: state?.days ?? [],
|
|
2787
|
+
...state?.months !== void 0 && { months: state.months }
|
|
2788
|
+
};
|
|
2127
2789
|
}
|
|
2128
2790
|
async writeUsageState(name, state) {
|
|
2129
2791
|
await writeJson(this.appUsagePath(name), state);
|
|
2130
2792
|
}
|
|
2793
|
+
get authStatePath() {
|
|
2794
|
+
return join(this.root, "auth.json");
|
|
2795
|
+
}
|
|
2796
|
+
async readAuthState() {
|
|
2797
|
+
const state = await readJson(this.authStatePath);
|
|
2798
|
+
return {
|
|
2799
|
+
tokens: state?.tokens ?? [],
|
|
2800
|
+
sessions: state?.sessions ?? []
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
async writeAuthState(state) {
|
|
2804
|
+
await writeJson(this.authStatePath, state);
|
|
2805
|
+
}
|
|
2131
2806
|
async listInstalled() {
|
|
2132
2807
|
let entries;
|
|
2133
2808
|
try {
|
|
@@ -2146,6 +2821,6 @@ var Store = class {
|
|
|
2146
2821
|
}
|
|
2147
2822
|
};
|
|
2148
2823
|
//#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 };
|
|
2824
|
+
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
2825
|
|
|
2151
|
-
//# sourceMappingURL=store-
|
|
2826
|
+
//# sourceMappingURL=store-DoAGOynU.mjs.map
|