@stage-labs/metro 0.1.0-beta.221 → 0.1.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.221",
3
+ "version": "0.1.0-beta.223",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.221",
3
+ "version": "0.1.0-beta.223",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -50,6 +50,7 @@ import { materializeFrom, reloadFrom } from '../stations/materialize.js';
50
50
  import type { StationName } from '@metro-labs/core/station-names';
51
51
  import { startUploadReaper } from '../files/upload-store.js';
52
52
  import { startAttachReaper } from '../files/attach-reaper.js';
53
+ import { startResourceSampler } from '../server/resources.js';
53
54
 
54
55
  installCrashGuard();
55
56
  acquireLock(join(STATE_DIR, '.tail-lock'));
@@ -142,6 +143,7 @@ installBearerSessions(agentsDir(), localOwner);
142
143
  syncPluginServers(readLocalConnectors());
143
144
  startUploadReaper();
144
145
  startAttachReaper();
146
+ startResourceSampler();
145
147
  announceLocalEndpoint();
146
148
  tunnel?.start();
147
149
  log.info(
@@ -0,0 +1,127 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { agentCommand, agentUser, agentUserExpected, claudeBin } from '../agent-user/user.js';
5
+ import { runningAsRoot } from '../metro-user/privilege.js';
6
+ import { PRIVACY_ENV } from './setup.js';
7
+
8
+ const REQUEST_ID = 'metro-usage';
9
+ const REQUEST = `${JSON.stringify({ type: 'control_request', request_id: REQUEST_ID, request: { subtype: 'get_usage', skip_behaviors: true } })}\n`;
10
+ const ARGS = [
11
+ '-p',
12
+ '--setting-sources',
13
+ 'project',
14
+ '--strict-mcp-config',
15
+ '--mcp-config',
16
+ '{"mcpServers":{}}',
17
+ '--no-session-persistence',
18
+ '--input-format',
19
+ 'stream-json',
20
+ '--output-format',
21
+ 'stream-json',
22
+ '--verbose',
23
+ ];
24
+ const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
25
+ const PROBE_ENV: Record<string, string> = {
26
+ ...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
27
+ DISABLE_AUTOUPDATER: '1',
28
+ DISABLE_BUG_COMMAND: '1',
29
+ ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
30
+ };
31
+ const TIMEOUT_MS = 20_000;
32
+ const KILL_GRACE_MS = 2_000;
33
+ const LINE_MAX = 1024 * 1024;
34
+
35
+ export interface UsageProbeDeps {
36
+ command?: string[];
37
+ timeoutMs?: number;
38
+ }
39
+
40
+ export function usageProbeCommand(): string[] | null {
41
+ if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
42
+ return agentCommand([claudeBin(), ...ARGS], PROBE_ENV);
43
+ }
44
+
45
+ function answerOf(line: string): Record<string, unknown> | null {
46
+ try {
47
+ const parsed: unknown = JSON.parse(line);
48
+ if (!isRecord(parsed) || parsed.type !== 'control_response' || !isRecord(parsed.response)) return null;
49
+ return parsed.response.request_id === REQUEST_ID ? parsed.response : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function limitsOf(answer: Record<string, unknown>): unknown {
56
+ if (answer.subtype !== 'success') throw new Error(`Claude Code refused the usage request: ${typeof answer.error === 'string' ? answer.error : 'no reason given'}`);
57
+ return isRecord(answer.response) ? (answer.response.rate_limits ?? null) : null;
58
+ }
59
+
60
+ export function usageIn(output: string): unknown {
61
+ const answer = output
62
+ .split('\n')
63
+ .map(answerOf)
64
+ .find((found) => found !== null);
65
+ if (answer === undefined || answer === null) throw new Error('Claude Code gave no usage answer');
66
+ return limitsOf(answer);
67
+ }
68
+
69
+ export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
70
+ const command = deps.command ?? usageProbeCommand();
71
+ if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
72
+ const [file = '', ...args] = command;
73
+ return new Promise((resolve, reject) => {
74
+ const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV } });
75
+ let carry = '';
76
+ let settled = false;
77
+ let hardKill: ReturnType<typeof setTimeout> | undefined;
78
+ const settle = (run: () => void): void => {
79
+ if (settled) return;
80
+ settled = true;
81
+ run();
82
+ };
83
+ const overdue = setTimeout(() => {
84
+ settle(() => {
85
+ reject(new Error('Claude Code did not report the usage in time'));
86
+ });
87
+ child.stdout.destroy();
88
+ child.kill('SIGTERM');
89
+ hardKill = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
90
+ }, deps.timeoutMs ?? TIMEOUT_MS);
91
+ const take = (line: string): void => {
92
+ const answer = answerOf(line);
93
+ if (answer === null) return;
94
+ settle(() => {
95
+ try {
96
+ resolve(limitsOf(answer));
97
+ } catch (err) {
98
+ reject(err instanceof Error ? err : new Error(errMsg(err)));
99
+ }
100
+ });
101
+ };
102
+ child.stdout.on('data', (chunk: Buffer) => {
103
+ const lines = (carry + chunk.toString('utf8')).split('\n');
104
+ carry = lines.pop() ?? '';
105
+ if (carry.length > LINE_MAX) carry = '';
106
+ lines.forEach(take);
107
+ });
108
+ child.stdin.on('error', (err) => {
109
+ log.debug({ err: errMsg(err) }, 'claude-usage: Claude Code closed its input early');
110
+ });
111
+ child.on('error', (err) => {
112
+ clearTimeout(overdue);
113
+ settle(() => {
114
+ reject(err);
115
+ });
116
+ });
117
+ child.on('close', () => {
118
+ clearTimeout(overdue);
119
+ clearTimeout(hardKill);
120
+ take(carry);
121
+ settle(() => {
122
+ reject(new Error('Claude Code gave no usage answer'));
123
+ });
124
+ });
125
+ child.stdin.end(REQUEST);
126
+ });
127
+ }
@@ -10,6 +10,7 @@ export const CODEX_ISSUER = 'https://auth.openai.com';
10
10
  export const CODEX_REDIRECT = 'http://localhost:1455/auth/callback';
11
11
  const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
12
12
  const AUTH_CLAIM = 'https://api.openai.com/auth';
13
+ const PROFILE_CLAIM = 'https://api.openai.com/profile';
13
14
  const ACCESS_TOKEN_TTL_MS = 55 * 60_000;
14
15
 
15
16
  export interface CodexTokens {
@@ -76,7 +77,12 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> {
76
77
  export function claimsOf(idToken: string): { accountId: string | null; email: string | null; plan: string | null } {
77
78
  const payload = decodeJwtPayload(idToken);
78
79
  const auth = isRecord(payload[AUTH_CLAIM]) ? payload[AUTH_CLAIM] : {};
79
- return { accountId: nonEmpty(auth.chatgpt_account_id), email: nonEmpty(payload.email), plan: nonEmpty(auth.chatgpt_plan_type) };
80
+ const profile = isRecord(payload[PROFILE_CLAIM]) ? payload[PROFILE_CLAIM] : {};
81
+ return {
82
+ accountId: nonEmpty(auth.chatgpt_account_id),
83
+ email: nonEmpty(payload.email) ?? nonEmpty(profile.email),
84
+ plan: nonEmpty(auth.chatgpt_plan_type),
85
+ };
80
86
  }
81
87
 
82
88
  interface TokenResponse {
@@ -10,10 +10,11 @@ import { parseEvent, SseParser, type SseEvent } from './frames.js';
10
10
  import type { Connection } from './model-config.js';
11
11
  import { listCache } from './model-lists.js';
12
12
  import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
13
- import { noteUsageHeaders } from './usage.js';
13
+ import { codexUsageBody, noteUsageHeaders, type Reported } from './usage.js';
14
14
 
15
15
  const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
16
16
  const INVALID = [400, 404, 422];
17
+ const USAGE_TIMEOUT_MS = 10_000;
17
18
 
18
19
  export interface CodexDeps {
19
20
  base?: string;
@@ -110,6 +111,24 @@ export async function codexMessages(
110
111
  answerWhole(res, frames + translator.close(), conn.id);
111
112
  }
112
113
 
114
+ const jsonHeaders = (tokens: CodexTokens): Record<string, string> => ({ ...headersFor(tokens, randomUUID()), accept: 'application/json' });
115
+
116
+ export const codexUsageUrl = (base = CODEX_BASE): string => `${base.replace(/\/codex\/?$/, '')}/wham/usage`;
117
+
118
+ export async function codexUsageNow(conn: Connection, deps: CodexDeps, state: CodexState): Promise<Reported | null> {
119
+ const fetchImpl = deps.fetchImpl ?? fetch;
120
+ const res = await reach(
121
+ () => currentTokens(conn, deps, state),
122
+ (tokens) => refreshed(state, conn.id, tokens, sourceOf(deps)),
123
+ (tokens) => fetchImpl(codexUsageUrl(deps.base), { headers: jsonHeaders(tokens), redirect: 'manual', signal: AbortSignal.timeout(USAGE_TIMEOUT_MS) }),
124
+ );
125
+ if (!res.ok) {
126
+ await res.body?.cancel();
127
+ throw new GatewayError(res.status, errorKind(res.status, INVALID), `Codex would not report its usage (${String(res.status)})`);
128
+ }
129
+ return codexUsageBody(await res.json());
130
+ }
131
+
113
132
  export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
114
133
  const version = await learnCodexVersion();
115
134
  const base = deps.base ?? CODEX_BASE;
@@ -118,7 +137,7 @@ export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'sa
118
137
 
119
138
  async function listCodex(tokens: CodexTokens, version: string, base: string, fetchImpl: typeof fetch): Promise<string[]> {
120
139
  const res = await fetchImpl(`${base}/models?client_version=${version}`, {
121
- headers: { ...headersFor(tokens, randomUUID()), accept: 'application/json' },
140
+ headers: jsonHeaders(tokens),
122
141
  redirect: 'manual',
123
142
  });
124
143
  if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `Codex would not list models (${String(res.status)})`);
@@ -22,7 +22,7 @@ import {
22
22
  withEffort,
23
23
  withThinkingFor,
24
24
  } from './effort.js';
25
- import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
25
+ import { notReady, PASSTHROUGH_ID, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
26
26
  import { codexMessages, sharedCodexState } from './codex.js';
27
27
  import { geminiMessages, sharedGeminiState } from './gemini.js';
28
28
  import { countTokens } from './subscription.js';
@@ -87,7 +87,7 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
87
87
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
88
88
  }
89
89
 
90
- const PASSTHROUGH: Connection = { id: 'passthrough', provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
90
+ const PASSTHROUGH: Connection = { id: PASSTHROUGH_ID, provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
91
91
 
92
92
  const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
93
93
 
@@ -7,9 +7,10 @@ import { openrouterModels, openrouterZdrModels } from './openrouter.js';
7
7
  import { anthropicModels, bedrockModels } from './provider-models.js';
8
8
  import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
9
9
  import { sessionRunning, stopSession } from '../claude/session.js';
10
- import { forgetOne } from './usage.js';
10
+ import { forgetOne, forgetReported } from './usage.js';
11
11
  import {
12
12
  addConnection,
13
+ connectionOf,
13
14
  parseModelConfig,
14
15
  readModelConfig,
15
16
  removeConnection,
@@ -19,7 +20,8 @@ import {
19
20
  type ModelConfig,
20
21
  } from './model-config.js';
21
22
  import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
22
- import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES, refreshUsage } from './model-signin.js';
23
+ import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
24
+ import { refreshUsage } from './usage-refresh.js';
23
25
 
24
26
  const PATH = '/api/model';
25
27
  const CONNECTIONS = '/api/model/connections';
@@ -73,7 +75,9 @@ async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): P
73
75
  }
74
76
 
75
77
  async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id: string): Promise<unknown> {
78
+ const before = connectionOf(store.read(), id)?.apiKey;
76
79
  const next = await withBody(req, (body) => updateConnection(store.read(), id, body));
80
+ if (connectionOf(next, id)?.apiKey !== before) forgetReported(id);
77
81
  return kept(store, next, deps, 'model-api: connection changed', { connection: id });
78
82
  }
79
83
 
@@ -3,7 +3,7 @@ import { newId } from '@metro-labs/core/ids';
3
3
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
4
4
  import { agentsDir } from '../agents/files.js';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
- import type { CodexTokens } from './codex-auth.js';
6
+ import { claimsOf, type CodexTokens } from './codex-auth.js';
7
7
  import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gemini-auth.js';
8
8
 
9
9
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
@@ -189,6 +189,14 @@ export function resolveRoute(requested: string, cfg: ModelConfig): Route | null
189
189
  export const routeLabel = (route: Route): string =>
190
190
  route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
191
191
 
192
+ export const PASSTHROUGH_ID = 'passthrough';
193
+
194
+ function signedInAs(c: Connection): { account: string | null; plan: string | null } {
195
+ if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
196
+ const claims = claimsOf(c.codex.idToken);
197
+ return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
198
+ }
199
+
192
200
  function publicConnection(c: Connection): Record<string, unknown> {
193
201
  return {
194
202
  id: c.id,
@@ -199,8 +207,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
199
207
  region: c.region,
200
208
  zdr: c.zdr,
201
209
  signedIn: c.codex !== null || c.gemini !== null,
202
- account: c.codex?.email ?? c.gemini?.email ?? null,
203
- plan: c.codex?.plan ?? c.gemini?.tier ?? null,
210
+ ...signedInAs(c),
204
211
  };
205
212
  }
206
213
 
@@ -2,17 +2,15 @@ import type { IncomingMessage } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
3
  import { readJsonBody } from '@metro-labs/http/api-http';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
- import { errMsg, log } from '@metro-labs/core/log';
5
+ import { log } from '@metro-labs/core/log';
6
6
  import { beginLogin, finishLogin, readCodexCliAuth, type CodexTokens } from './codex-auth.js';
7
7
  import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
8
  import { codexModels, currentTokens, sharedCodexState } from './codex.js';
9
9
  import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
10
10
  import { onboard, parseGeminiProject } from './gemini-setup.js';
11
11
  import { currentGeminiTokens, listGeminiModels, sharedGeminiState, type GeminiDeps } from './gemini.js';
12
- import { openrouterCredits } from './openrouter.js';
13
- import { lastServed } from './served.js';
14
12
  import { listCache } from './model-lists.js';
15
- import { geminiUsage, noteUsage, openrouterUsage, usageOf } from './usage.js';
13
+ import { forgetReported } from './usage.js';
16
14
  import {
17
15
  addConnection,
18
16
  connectionOf,
@@ -22,8 +20,7 @@ import {
22
20
  type ModelConfig,
23
21
  type Provider,
24
22
  } from './model-config.js';
25
- import { asApiError, askedConnection, BODY_MAX, connectionFor, CREDITS_TTL_MS, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
26
-
23
+ import { asApiError, askedConnection, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
27
24
 
28
25
  function connectionToFill(store: Store, req: IncomingMessage, provider: Provider): { cfg: ModelConfig; id: string } {
29
26
  const asked = askedConnection(req);
@@ -36,8 +33,9 @@ function connectionToFill(store: Store, req: IncomingMessage, provider: Provider
36
33
  return { cfg, id: cfg.connections.at(-1)?.id ?? '' };
37
34
  }
38
35
 
39
- const saved = (store: Store, cfg: ModelConfig, note: string, fields: Record<string, unknown>): unknown => {
36
+ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connection: string } & Record<string, unknown>): unknown => {
40
37
  store.write(cfg);
38
+ forgetReported(fields.connection);
41
39
  log.info(fields, note);
42
40
  return settingsBody(cfg);
43
41
  };
@@ -54,7 +52,7 @@ async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps,
54
52
  return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected by device code') };
55
53
  }
56
54
 
57
- const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
55
+ export const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
58
56
  issuer: deps.issuer,
59
57
  fetchImpl: deps.fetchImpl,
60
58
  base: deps.codexBase,
@@ -114,7 +112,7 @@ const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
114
112
  },
115
113
  });
116
114
 
117
- async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
115
+ export async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
118
116
  if (conn.gemini === null) throw new ApiError('this connection is not signed in yet', 400);
119
117
  const tokens = await currentGeminiTokens(conn, geminiDeps(deps, store), sharedGeminiState);
120
118
  return listGeminiModels(tokens, geminiDeps(deps, store));
@@ -161,42 +159,3 @@ export const GEMINI_ROUTES: Record<string, Route> = {
161
159
  export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
162
160
  return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
163
161
  }
164
-
165
- async function refreshOne(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
166
- if (conn.apiKey === '') return;
167
- const seen = usageOf(conn.id);
168
- if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
169
- try {
170
- const credits = await openrouterCredits(conn.apiKey, deps.openrouterBase, deps.fetchImpl);
171
- noteUsage(conn.id, openrouterUsage(credits.total, credits.spent, new Date(now)));
172
- } catch (err) {
173
- log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
174
- }
175
- }
176
-
177
- const modelsInUse = (conn: Connection): Set<string> => {
178
- const served = lastServed();
179
- return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
180
- };
181
-
182
- async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
183
- if (conn.gemini === null) return;
184
- const inUse = modelsInUse(conn);
185
- const seen = usageOf(conn.id);
186
- const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
187
- if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
188
- try {
189
- const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
190
- const usage = geminiUsage(rows, new Date(now));
191
- if (usage !== null) noteUsage(conn.id, usage);
192
- } catch (err) {
193
- log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
194
- }
195
- }
196
-
197
- export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
198
- for (const conn of store.read().connections) {
199
- if (conn.provider === 'openrouter') await refreshOne(conn, deps, now);
200
- if (conn.provider === 'gemini') await refreshQuota(conn, deps, store, now);
201
- }
202
- }
@@ -24,6 +24,8 @@ export interface ModelApiDeps {
24
24
  bedrockControlBase?: string;
25
25
  setup?: SetupDeps;
26
26
  restartSession?: () => boolean;
27
+ claudeUsage?: () => Promise<unknown>;
28
+ usageWaitMs?: number;
27
29
  }
28
30
 
29
31
  export interface Store {
@@ -2,9 +2,11 @@ import { isRecord } from '@metro-labs/core/is-record';
2
2
  import { GatewayError } from './forward.js';
3
3
  import { listCache } from './model-lists.js';
4
4
  import { stringOf } from '@metro-labs/http/api-http';
5
+ import type { KeySpend } from './usage.js';
5
6
 
6
7
  export const OPENROUTER_BASE = 'https://openrouter.ai/api';
7
8
  const MODELS_MAX = 2000;
9
+ const KEY_TIMEOUT_MS = 10_000;
8
10
 
9
11
  export interface OpenRouterModel {
10
12
  id: string;
@@ -69,27 +71,23 @@ async function listModels(base: string, fetchImpl: typeof fetch): Promise<OpenRo
69
71
  export const openrouterModels = (base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> =>
70
72
  modelLists.get(base, () => listModels(base, fetchImpl));
71
73
 
72
- export interface OpenRouterCredits {
73
- total: number;
74
- spent: number;
75
- }
74
+ const nullableAmount = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
76
75
 
77
- export async function openrouterCredits(
78
- apiKey: string,
79
- base = OPENROUTER_BASE,
80
- fetchImpl: typeof fetch = fetch,
81
- ): Promise<OpenRouterCredits> {
82
- const res = await fetchImpl(`${base}/v1/credits`, {
76
+ export async function openrouterKey(apiKey: string, base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<KeySpend> {
77
+ const res = await fetchImpl(`${base}/v1/key`, {
83
78
  headers: { accept: 'application/json', authorization: `Bearer ${apiKey}` },
84
79
  redirect: 'manual',
80
+ signal: AbortSignal.timeout(KEY_TIMEOUT_MS),
85
81
  });
86
- if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not report the credits (${String(res.status)})`);
82
+ if (!res.ok) {
83
+ await res.body?.cancel();
84
+ throw new GatewayError(res.status, 'api_error', `OpenRouter would not describe the stored key (${String(res.status)})`);
85
+ }
87
86
  const body: unknown = await res.json();
88
87
  const data = isRecord(body) && isRecord(body.data) ? body.data : {};
89
- const total = Number(data.total_credits);
90
- const spent = Number(data.total_usage);
91
- if (!Number.isFinite(total) || !Number.isFinite(spent)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no credit figures');
92
- return { total, spent };
88
+ const spent = nullableAmount(data.usage);
89
+ if (spent === null) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no spend for the key');
90
+ return { limit: nullableAmount(data.limit), remaining: nullableAmount(data.limit_remaining), spent };
93
91
  }
94
92
 
95
93
  async function listZdr(base: string, fetchImpl: typeof fetch): Promise<string[]> {
@@ -0,0 +1,102 @@
1
+ import { errMsg, log } from '@metro-labs/core/log';
2
+ import { readClaudeUsage } from '../claude/usage-probe.js';
3
+ import { codexUsageNow, sharedCodexState } from './codex.js';
4
+ import { openrouterKey } from './openrouter.js';
5
+ import { lastServed } from './served.js';
6
+ import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, noteUsage, openrouterUsage, probeAnswer, usageOf, type Reported } from './usage.js';
7
+ import { PASSTHROUGH_ID, routedConnection, type Connection, type ModelConfig } from './model-config.js';
8
+ import { codexDepsFor, geminiModelsOf } from './model-signin.js';
9
+ import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
10
+
11
+ const WAIT_MS = 4_000;
12
+ const LOGIN_PROBE = 'claude-login';
13
+
14
+ const fresh = (id: string, now: number): boolean => {
15
+ const seen = usageOf(id);
16
+ return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
17
+ };
18
+
19
+ const due = (id: string, now: number): boolean => !fresh(id, now) && mayProbe(id, now, CREDITS_TTL_MS);
20
+
21
+ async function probe(what: string, run: () => Promise<void>): Promise<void> {
22
+ try {
23
+ await run();
24
+ } catch (err) {
25
+ log.warn({ err: errMsg(err) }, `model-api: could not read the ${what}`);
26
+ }
27
+ }
28
+
29
+ async function refreshKey(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
30
+ if (conn.apiKey === '' || !due(conn.id, now)) return;
31
+ await probe('OpenRouter key spend', async () => {
32
+ noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
33
+ });
34
+ }
35
+
36
+ async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
37
+ if (conn.codex === null || !due(conn.id, now)) return;
38
+ await probe('Codex usage', async () => {
39
+ const usage = await codexUsageNow(conn, codexDepsFor(deps, store, conn.id), sharedCodexState);
40
+ if (usage !== null) noteUsage(conn.id, usage);
41
+ });
42
+ }
43
+
44
+ const modelsInUse = (conn: Connection): Set<string> => {
45
+ const served = lastServed();
46
+ return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
47
+ };
48
+
49
+ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
50
+ if (conn.gemini === null) return;
51
+ const inUse = modelsInUse(conn);
52
+ const seen = usageOf(conn.id);
53
+ const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
54
+ if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
55
+ await probe('Gemini quota', async () => {
56
+ const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
57
+ const usage = geminiUsage(rows, new Date(now));
58
+ if (usage !== null) noteUsage(conn.id, usage);
59
+ });
60
+ }
61
+
62
+ const loginIds = (cfg: ModelConfig): string[] => [
63
+ ...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
64
+ ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
65
+ ];
66
+
67
+ const olderThan = (seen: Reported | undefined, usage: Reported): boolean => seen === undefined || Date.parse(seen.at) < Date.parse(usage.at);
68
+
69
+ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
70
+ const stale = loginIds(cfg).filter((id) => !fresh(id, now));
71
+ if (stale.length === 0) return;
72
+ if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
73
+ await probe('usage of the Claude Code login', async () => {
74
+ const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
75
+ if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
76
+ });
77
+ const usage = probeAnswer(LOGIN_PROBE);
78
+ if (usage === undefined || now - Date.parse(usage.at) >= CREDITS_TTL_MS) return;
79
+ for (const id of stale) if (olderThan(usageOf(id), usage)) noteUsage(id, usage);
80
+ }
81
+
82
+ function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
83
+ if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
84
+ if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
85
+ if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
86
+ return Promise.resolve();
87
+ }
88
+
89
+ async function within(ms: number, work: Promise<unknown>): Promise<void> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const waited = new Promise<void>((resolve) => {
92
+ timer = setTimeout(resolve, ms);
93
+ });
94
+ await Promise.race([work, waited]);
95
+ clearTimeout(timer);
96
+ }
97
+
98
+ export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
99
+ const cfg = store.read();
100
+ const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
101
+ await within(deps.usageWaitMs ?? WAIT_MS, work);
102
+ }
@@ -1,3 +1,6 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { stringOf } from '@metro-labs/http/api-http';
3
+
1
4
  type Key = string;
2
5
 
3
6
  interface UsageWindow {
@@ -22,10 +25,12 @@ export interface ProviderUsage {
22
25
  tally: Tally | null;
23
26
  }
24
27
 
25
- type Reported = Omit<ProviderUsage, 'tally'>;
28
+ export type Reported = Omit<ProviderUsage, 'tally'>;
26
29
 
27
30
  const latest = new Map<Key, Reported>();
28
31
  const tallies = new Map<Key, Tally>();
32
+ const probed = new Map<Key, number>();
33
+ const answers = new Map<Key, Reported>();
29
34
 
30
35
  export const noteUsage = (key: Key, usage: Reported): void => {
31
36
  latest.set(key, usage);
@@ -46,11 +51,31 @@ export const usageOf = (key: Key): Reported | undefined => latest.get(key);
46
51
  export const forgetOne = (key: Key): void => {
47
52
  latest.delete(key);
48
53
  tallies.delete(key);
54
+ probed.delete(key);
55
+ };
56
+
57
+ export const forgetReported = (key: Key): void => {
58
+ latest.delete(key);
59
+ probed.delete(key);
49
60
  };
50
61
 
51
62
  export const forgetUsage = (): void => {
52
63
  latest.clear();
53
64
  tallies.clear();
65
+ probed.clear();
66
+ answers.clear();
67
+ };
68
+
69
+ export function mayProbe(key: Key, now: number, every: number): boolean {
70
+ if (now - (probed.get(key) ?? Number.NEGATIVE_INFINITY) < every) return false;
71
+ probed.set(key, now);
72
+ return true;
73
+ }
74
+
75
+ export const probeAnswer = (key: Key): Reported | undefined => answers.get(key);
76
+
77
+ export const keepProbeAnswer = (key: Key, usage: Reported): void => {
78
+ answers.set(key, usage);
54
79
  };
55
80
 
56
81
  export interface Counted {
@@ -198,13 +223,16 @@ function codexWindow(headers: Headers, kind: 'primary' | 'secondary', now: Date)
198
223
  return { label: windowLabel(Number.isFinite(minutes) ? minutes : null), used, resetAt, detail: null };
199
224
  }
200
225
 
201
- function codexCredits(headers: Headers): UsageWindow | null {
202
- if (headers.get('x-codex-credits-has-credits') !== 'true') return null;
203
- const balance = Number(headers.get('x-codex-credits-balance') ?? 'none');
226
+ function creditsLeft(hasCredits: boolean, raw: string | null): UsageWindow | null {
227
+ if (!hasCredits) return null;
228
+ const balance = Number(raw ?? 'none');
204
229
  if (!Number.isFinite(balance)) return null;
205
230
  return { label: 'Credits', used: null, resetAt: null, detail: `${balance.toLocaleString('en-US')} left` };
206
231
  }
207
232
 
233
+ const codexCredits = (headers: Headers): UsageWindow | null =>
234
+ creditsLeft(headers.get('x-codex-credits-has-credits') === 'true', headers.get('x-codex-credits-balance'));
235
+
208
236
  export function codexUsage(headers: Headers, now = new Date()): Reported | null {
209
237
  const windows = [codexWindow(headers, 'primary', now), codexWindow(headers, 'secondary', now), codexCredits(headers)].filter(
210
238
  (w): w is UsageWindow => w !== null,
@@ -214,6 +242,77 @@ export function codexUsage(headers: Headers, now = new Date()): Reported | null
214
242
  return { windows, note: reached === null || reached === '' ? null : reached.replaceAll('_', ' '), at: now.toISOString() };
215
243
  }
216
244
 
245
+ const present = (w: UsageWindow | null): w is UsageWindow => w !== null;
246
+
247
+ const textOf = (raw: unknown): string | null => (typeof raw === 'number' || typeof raw === 'string' ? String(raw) : null);
248
+
249
+ const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
250
+
251
+ const SECONDS_PER_MINUTE = 60;
252
+
253
+ function codexBodyWindow(raw: unknown, now: Date): UsageWindow | null {
254
+ if (!isRecord(raw)) return null;
255
+ const percent = numberOf(raw.used_percent);
256
+ if (percent === null) return null;
257
+ const seconds = numberOf(raw.limit_window_seconds);
258
+ const minutes = seconds === null || seconds <= 0 ? null : Math.ceil(seconds / SECONDS_PER_MINUTE);
259
+ const resetAt = whenFrom(textOf(raw.reset_at), now) ?? whenFrom(textOf(raw.reset_after_seconds), now, true);
260
+ return { label: windowLabel(minutes), used: clamp(percent / 100), resetAt, detail: null };
261
+ }
262
+
263
+ function codexWall(limit: Record<string, unknown>, reached: unknown): string | null {
264
+ if (limit.limit_reached !== true && limit.allowed !== false) return null;
265
+ const kind = isRecord(reached) ? stringOf(reached.type) : '';
266
+ return kind === '' ? 'limit reached' : kind.replaceAll('_', ' ');
267
+ }
268
+
269
+ export function codexUsageBody(body: unknown, now = new Date()): Reported | null {
270
+ if (!isRecord(body)) return null;
271
+ const limit = isRecord(body.rate_limit) ? body.rate_limit : {};
272
+ const credits = isRecord(body.credits) ? body.credits : {};
273
+ const windows = [
274
+ codexBodyWindow(limit.primary_window, now),
275
+ codexBodyWindow(limit.secondary_window, now),
276
+ creditsLeft(credits.has_credits === true, textOf(credits.balance)),
277
+ ].filter(present);
278
+ return windows.length === 0 ? null : { windows, note: codexWall(limit, body.rate_limit_reached_type), at: now.toISOString() };
279
+ }
280
+
281
+ const CLAUDE_WINDOWS: [string, string][] = [
282
+ ['five_hour', '5-hour window'],
283
+ ['seven_day', 'Weekly'],
284
+ ['seven_day_sonnet', 'Weekly, Sonnet'],
285
+ ['seven_day_opus', 'Weekly, Opus'],
286
+ ];
287
+
288
+ function percentWindow(label: string, raw: unknown, now: Date): UsageWindow | null {
289
+ if (!isRecord(raw)) return null;
290
+ const percent = numberOf(raw.utilization) ?? numberOf(raw.percent);
291
+ if (percent === null) return null;
292
+ return { label, used: clamp(percent / 100), resetAt: whenFrom(textOf(raw.resets_at), now), detail: null };
293
+ }
294
+
295
+ function scopedName(entry: Record<string, unknown>): string {
296
+ if (stringOf(entry.display_name) !== '') return stringOf(entry.display_name);
297
+ if (entry.kind !== 'weekly_scoped' || !isRecord(entry.scope) || !isRecord(entry.scope.model)) return '';
298
+ return stringOf(entry.scope.model.display_name);
299
+ }
300
+
301
+ function scopedWindows(limits: Record<string, unknown>, now: Date): (UsageWindow | null)[] {
302
+ const listed = [limits.model_scoped, limits.limits].flatMap((list): unknown[] => (Array.isArray(list) ? (list as unknown[]) : []));
303
+ return listed.map((entry) => {
304
+ const name = isRecord(entry) ? scopedName(entry) : '';
305
+ return name === '' ? null : percentWindow(`Weekly, ${name}`, entry, now);
306
+ });
307
+ }
308
+
309
+ export function claudeLoginUsage(limits: unknown, now = new Date()): Reported | null {
310
+ if (!isRecord(limits)) return null;
311
+ const all = [...CLAUDE_WINDOWS.map(([key, label]) => percentWindow(label, limits[key], now)), ...scopedWindows(limits, now)].filter(present);
312
+ const windows = all.filter((w, index) => all.findIndex((other) => other.label === w.label) === index);
313
+ return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
314
+ }
315
+
217
316
  export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
218
317
  const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
219
318
  if (usage !== null) noteUsage(key, usage);
@@ -234,11 +333,16 @@ export function geminiUsage(rows: QuotaRow[], now = new Date()): Reported | null
234
333
  return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
235
334
  }
236
335
 
237
- export function openrouterUsage(total: number, spent: number, now = new Date()): Reported {
238
- const used = total > 0 ? clamp(spent / total) : null;
239
- return {
240
- windows: [{ label: 'Credits', used, resetAt: null, detail: `${dollars(spent)} of ${dollars(total)} used` }],
241
- note: null,
242
- at: now.toISOString(),
243
- };
336
+ export interface KeySpend {
337
+ limit: number | null;
338
+ remaining: number | null;
339
+ spent: number;
340
+ }
341
+
342
+ function keyWindow(key: KeySpend): UsageWindow {
343
+ if (key.limit === null || key.limit <= 0) return { label: 'Credits', used: null, resetAt: null, detail: `${dollars(key.spent)} used, no limit on this key` };
344
+ const spent = key.remaining === null ? key.spent : Math.max(0, key.limit - key.remaining);
345
+ return { label: 'Credits', used: clamp(spent / key.limit), resetAt: null, detail: `${dollars(spent)} of ${dollars(key.limit)} used` };
244
346
  }
347
+
348
+ export const openrouterUsage = (key: KeySpend, now = new Date()): Reported => ({ windows: [keyWindow(key)], note: null, at: now.toISOString() });
@@ -44,6 +44,7 @@ import {
44
44
  readLocalAgentFile,
45
45
  } from '../agents/file-admin.js';
46
46
  import { readModelConfig } from '../gateway/model-config.js';
47
+ import { resourcesFor } from '../server/resources.js';
47
48
  import type { StationName } from '@metro-labs/core/station-names';
48
49
 
49
50
  export interface LocalModeDeps {
@@ -197,7 +198,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
197
198
  updateApi: { restart: deps.restart },
198
199
  controlApi: { restart: deps.restart, stop: deps.stop },
199
200
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
200
- machineApi: {},
201
+ machineApi: { resources: resourcesFor },
201
202
  modelApi: {},
202
203
  gateway: { config: readModelConfig },
203
204
  terminalApi: {},
@@ -13,6 +13,7 @@ const PATH = '/api/server';
13
13
 
14
14
  export interface MachineApiDeps {
15
15
  startedAt?: string;
16
+ resources?: (range: string) => unknown;
16
17
  }
17
18
 
18
19
  const bootedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
@@ -23,7 +24,7 @@ interface Disk {
23
24
  freeBytes: number;
24
25
  }
25
26
 
26
- async function diskInfo(path = homedir()): Promise<Disk | null> {
27
+ export async function diskInfo(path = homedir()): Promise<Disk | null> {
27
28
  try {
28
29
  const s = await statfs(path);
29
30
  return { path, totalBytes: s.bsize * s.blocks, freeBytes: s.bsize * s.bavail };
@@ -54,5 +55,9 @@ export async function machineInfo(startedAt = bootedAt): Promise<Record<string,
54
55
  }
55
56
 
56
57
  export function handleMachineRequest(req: IncomingMessage, res: ServerResponse, deps: MachineApiDeps): boolean {
57
- return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' }, () => machineInfo(deps.startedAt));
58
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' }, async () => {
59
+ const info = await machineInfo(deps.startedAt);
60
+ const range = new URL(req.url ?? '', 'http://localhost').searchParams.get('range');
61
+ return range === null || deps.resources === undefined ? info : { ...info, resources: deps.resources(range) };
62
+ });
58
63
  }
@@ -0,0 +1,160 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { cpus, freemem, homedir, totalmem } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { errMsg, log } from '@metro-labs/core/log';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { diskInfo } from './machine.js';
7
+
8
+ const SAMPLE_MS = 60_000;
9
+ const HOUR_MS = 3_600_000;
10
+ const KEEP_MS = 7 * 24 * HOUR_MS;
11
+ const POINTS = 240;
12
+ const COMPACT_EVERY = 1_440;
13
+ const RANGES: Readonly<Record<string, number>> = { '1h': HOUR_MS, '24h': 24 * HOUR_MS, '7d': KEEP_MS };
14
+ const FIELDS = ['at', 'cpu', 'memUsed', 'memTotal', 'diskUsed', 'diskTotal'] as const;
15
+
16
+ export type ResourceSample = Record<(typeof FIELDS)[number], number>;
17
+
18
+ interface CpuTicks {
19
+ busy: number;
20
+ total: number;
21
+ }
22
+
23
+ const state: { samples: ResourceSample[]; appended: number } = { samples: [], appended: 0 };
24
+
25
+ const resourcesFile = (): string => join(homedir(), '.metro', 'resources.jsonl');
26
+
27
+ function cpuTicks(): CpuTicks {
28
+ let busy = 0;
29
+ let total = 0;
30
+ for (const { times } of cpus()) {
31
+ const sum = times.user + times.nice + times.sys + times.idle + times.irq;
32
+ total += sum;
33
+ busy += sum - times.idle;
34
+ }
35
+ return { busy, total };
36
+ }
37
+
38
+ export function cpuPercent(before: CpuTicks, after: CpuTicks): number {
39
+ const total = after.total - before.total;
40
+ if (total <= 0) return 0;
41
+ return Math.min(100, Math.max(0, ((after.busy - before.busy) / total) * 100));
42
+ }
43
+
44
+ function toSample(value: unknown): ResourceSample | null {
45
+ if (typeof value !== 'object' || value === null) return null;
46
+ const out: Partial<ResourceSample> = {};
47
+ for (const field of FIELDS) {
48
+ const v: unknown = (value as Record<string, unknown>)[field];
49
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
50
+ out[field] = v;
51
+ }
52
+ return out as ResourceSample;
53
+ }
54
+
55
+ function parseLine(line: string): ResourceSample | null {
56
+ if (line.trim() === '') return null;
57
+ try {
58
+ return toSample(JSON.parse(line));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ const toLine = (sample: ResourceSample): string => `${JSON.stringify(sample)}\n`;
65
+
66
+ export function readSamples(file = resourcesFile(), now = Date.now()): ResourceSample[] {
67
+ let text: string;
68
+ try {
69
+ text = readFileSync(file, 'utf8');
70
+ } catch {
71
+ return [];
72
+ }
73
+ return text.split('\n').flatMap((line) => parseLine(line) ?? []).filter((s) => s.at > now - KEEP_MS);
74
+ }
75
+
76
+ function rewrite(file: string): void {
77
+ mkdirSync(dirname(file), { recursive: true });
78
+ writeFileSync(file, state.samples.map(toLine).join(''));
79
+ state.appended = 0;
80
+ }
81
+
82
+ export function recordSample(sample: ResourceSample, file = resourcesFile()): void {
83
+ state.samples.push(sample);
84
+ const first = state.samples.findIndex((s) => s.at > sample.at - KEEP_MS);
85
+ if (first > 0) state.samples.splice(0, first);
86
+ if (state.appended >= COMPACT_EVERY) {
87
+ rewrite(file);
88
+ return;
89
+ }
90
+ mkdirSync(dirname(file), { recursive: true });
91
+ appendFileSync(file, toLine(sample));
92
+ state.appended += 1;
93
+ }
94
+
95
+ export function loadSamples(file = resourcesFile()): void {
96
+ state.samples = readSamples(file);
97
+ state.appended = 0;
98
+ }
99
+
100
+ export async function takeSample(before: CpuTicks): Promise<{ sample: ResourceSample; ticks: CpuTicks }> {
101
+ const ticks = cpuTicks();
102
+ const disk = await diskInfo();
103
+ const memTotal = totalmem();
104
+ return {
105
+ ticks,
106
+ sample: {
107
+ at: Date.now(),
108
+ cpu: cpuPercent(before, ticks),
109
+ memUsed: memTotal - freemem(),
110
+ memTotal,
111
+ diskUsed: disk === null ? 0 : disk.totalBytes - disk.freeBytes,
112
+ diskTotal: disk?.totalBytes ?? 0,
113
+ },
114
+ };
115
+ }
116
+
117
+ export function startResourceSampler(): void {
118
+ try {
119
+ loadSamples();
120
+ rewrite(resourcesFile());
121
+ } catch (err) {
122
+ log.warn({ err: errMsg(err) }, 'resources: could not load the history');
123
+ }
124
+ let ticks = cpuTicks();
125
+ const run = (): void => {
126
+ takeSample(ticks)
127
+ .then((next) => {
128
+ ticks = next.ticks;
129
+ recordSample(next.sample);
130
+ })
131
+ .catch((err: unknown) => {
132
+ log.warn({ err: errMsg(err) }, 'resources: could not record a sample');
133
+ });
134
+ };
135
+ setInterval(run, SAMPLE_MS).unref?.();
136
+ }
137
+
138
+ function average(at: number, group: ResourceSample[]): ResourceSample {
139
+ const mean = (field: (typeof FIELDS)[number]): number => group.reduce((sum, s) => sum + s[field], 0) / group.length;
140
+ return { at, cpu: mean('cpu'), memUsed: mean('memUsed'), memTotal: mean('memTotal'), diskUsed: mean('diskUsed'), diskTotal: mean('diskTotal') };
141
+ }
142
+
143
+ export function resourceSeries(samples: ResourceSample[], rangeMs: number, now = Date.now()): { stepMs: number; samples: ResourceSample[] } {
144
+ const stepMs = Math.max(1, Math.ceil(rangeMs / POINTS / SAMPLE_MS)) * SAMPLE_MS;
145
+ const buckets = new Map<number, ResourceSample[]>();
146
+ for (const sample of samples) {
147
+ if (sample.at <= now - rangeMs || sample.at > now) continue;
148
+ const key = Math.floor(sample.at / stepMs) * stepMs;
149
+ const group = buckets.get(key);
150
+ if (group === undefined) buckets.set(key, [sample]);
151
+ else group.push(sample);
152
+ }
153
+ return { stepMs, samples: [...buckets].map(([at, group]) => average(at, group)) };
154
+ }
155
+
156
+ export function resourcesFor(range: string): { range: string; sampleMs: number; stepMs: number; samples: ResourceSample[] } {
157
+ const rangeMs = Object.hasOwn(RANGES, range) ? RANGES[range] : undefined;
158
+ if (rangeMs === undefined) throw new ApiError('range must be 1h, 24h or 7d', 400);
159
+ return { range, sampleMs: SAMPLE_MS, ...resourceSeries(state.samples, rangeMs) };
160
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.221"
2
+ "version": "0.1.0-beta.223"
3
3
  }