loki-mode 7.49.0 → 7.50.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.
@@ -3,18 +3,31 @@
3
3
  /**
4
4
  * Loki Mode Policy Engine - Cost Control System
5
5
  *
6
- * Per-project token budget tracking with configurable alert thresholds.
6
+ * Per-project token budget tracking with configurable alert thresholds, plus
7
+ * organization/tenant-level spend rollup and ceiling enforcement.
7
8
  *
8
9
  * Features:
9
10
  * - Per-project token budget tracking
10
11
  * - Alerts at configurable thresholds (default: 50%, 80%, 100%)
11
12
  * - Per-agent cost tracking (model type, tokens consumed, duration)
12
13
  * - Kill switch: emits shutdown event when budget exceeded
14
+ * - Org/tenant-level rollup: aggregates spend across all projects under an org
15
+ * - Org ceiling: pause or deny when aggregate org spend crosses a hard cap
13
16
  * - Cost data persisted to .loki/state/costs.json
17
+ *
18
+ * Org governance is an intelligent default: it is OFF (zero overhead, existing
19
+ * per-run behavior unchanged) unless an org ceiling is configured via an
20
+ * `org_max_tokens` resource policy. When configured, the org context is
21
+ * auto-detected (no required flags) from, in priority order:
22
+ * 1. explicit `org_id` on the resource policy
23
+ * 2. LOKI_ORG_ID / LOKI_TENANT_ID environment variables
24
+ * 3. the git remote owner (org/user) of the project directory
25
+ * 4. the literal 'default'
14
26
  */
15
27
 
16
28
  const fs = require('fs');
17
29
  const path = require('path');
30
+ const { execFileSync } = require('child_process');
18
31
  const { EventEmitter } = require('events');
19
32
 
20
33
  // -------------------------------------------------------------------
@@ -35,11 +48,19 @@ class CostController extends EventEmitter {
35
48
  this._stateFile = path.join(this._projectDir, '.loki', 'state', 'costs.json');
36
49
  this._state = this._loadState();
37
50
  this._budgetConfig = this._extractBudgetConfig(resourcePolicies || []);
51
+ this._orgConfig = this._extractOrgConfig(resourcePolicies || []);
38
52
  this._triggeredAlerts = new Set();
39
53
  // Per-project shutdown flags (keyed by projectId or 'global').
40
54
  // Using a Set instead of a single boolean ensures each project only
41
55
  // emits shutdown once even when multiple projects are tracked.
42
56
  this._shutdownEmittedProjects = new Set();
57
+ // Per-org shutdown flags (keyed by orgId). Mirrors the per-project set so
58
+ // each org enforces its ceiling at most once per controller lifetime.
59
+ this._shutdownEmittedOrgs = new Set();
60
+
61
+ // Resolve the org id once. Cheap when org governance is off (we still
62
+ // resolve so rollups are attributed, but ceiling logic is a no-op).
63
+ this._orgId = this._orgConfig ? this._detectOrgId() : null;
43
64
 
44
65
  // Restore previously triggered alerts
45
66
  if (this._state.triggeredAlerts) {
@@ -65,6 +86,7 @@ class CostController extends EventEmitter {
65
86
  return {
66
87
  projects: {},
67
88
  agents: {},
89
+ orgs: {},
68
90
  totalTokens: 0,
69
91
  triggeredAlerts: [],
70
92
  history: [],
@@ -95,6 +117,94 @@ class CostController extends EventEmitter {
95
117
  return null;
96
118
  }
97
119
 
120
+ /**
121
+ * Extract org/tenant ceiling config from resource policies.
122
+ *
123
+ * An org policy is any resource entry that declares `org_max_tokens`. This is
124
+ * intentionally separate from per-run `max_tokens` so the two ceilings can
125
+ * coexist (a run can be capped per-project AND roll up into an org cap).
126
+ *
127
+ * Returns null when no org ceiling is configured -- org governance stays off
128
+ * and per-run behavior is byte-for-byte unchanged.
129
+ *
130
+ * @param {Array} resourcePolicies
131
+ * @returns {{ maxTokens: number, alerts: Array<number>, onExceed: string,
132
+ * name: string, orgId: (string|null) }|null}
133
+ */
134
+ _extractOrgConfig(resourcePolicies) {
135
+ for (let i = 0; i < resourcePolicies.length; i++) {
136
+ const p = resourcePolicies[i];
137
+ if (p && typeof p.org_max_tokens === 'number' && p.org_max_tokens > 0) {
138
+ return {
139
+ maxTokens: p.org_max_tokens,
140
+ alerts: p.org_alerts || p.alerts || [50, 80, 100],
141
+ // Org ceilings default to 'pause' (deny new spend) rather than a hard
142
+ // shutdown, so a single project cannot tear down the whole org. Both
143
+ // 'pause' and 'shutdown' deny further spend; 'warn' is advisory only.
144
+ onExceed: p.org_on_exceed || 'pause',
145
+ name: p.name || 'org-budget',
146
+ orgId: typeof p.org_id === 'string' && p.org_id ? p.org_id : null,
147
+ };
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Auto-detect the org/tenant id with zero required configuration.
155
+ * Priority: explicit policy org_id > env > git remote owner > 'default'.
156
+ *
157
+ * @returns {string}
158
+ */
159
+ _detectOrgId() {
160
+ if (this._orgConfig && this._orgConfig.orgId) {
161
+ return this._orgConfig.orgId;
162
+ }
163
+ const envId = process.env.LOKI_ORG_ID || process.env.LOKI_TENANT_ID;
164
+ if (envId && String(envId).trim()) {
165
+ return String(envId).trim();
166
+ }
167
+ const remoteOwner = this._detectGitRemoteOwner();
168
+ if (remoteOwner) {
169
+ return remoteOwner;
170
+ }
171
+ return 'default';
172
+ }
173
+
174
+ /**
175
+ * Best-effort extraction of the owner segment from the project's git origin
176
+ * remote (e.g. "acme" from git@github.com:acme/repo.git). Returns null on any
177
+ * failure -- never throws, never blocks cost recording.
178
+ *
179
+ * @returns {string|null}
180
+ */
181
+ _detectGitRemoteOwner() {
182
+ try {
183
+ const url = execFileSync('git', ['config', '--get', 'remote.origin.url'], {
184
+ cwd: this._projectDir,
185
+ encoding: 'utf8',
186
+ stdio: ['ignore', 'pipe', 'ignore'],
187
+ }).trim();
188
+ if (!url) return null;
189
+ // Strip protocol/host and trailing .git, then take the owner segment.
190
+ // Handles: git@host:owner/repo.git, https://host/owner/repo(.git), ssh://...
191
+ let p = url.replace(/\.git$/, '');
192
+ const scp = p.match(/^[^@]+@[^:]+:(.+)$/); // scp-like syntax
193
+ if (scp) {
194
+ p = scp[1];
195
+ } else {
196
+ p = p.replace(/^[a-z]+:\/\/[^/]+\//i, '');
197
+ }
198
+ const segments = p.split('/').filter(Boolean);
199
+ if (segments.length >= 2) {
200
+ return segments[segments.length - 2];
201
+ }
202
+ return null;
203
+ } catch (_) {
204
+ return null;
205
+ }
206
+ }
207
+
98
208
  // -----------------------------------------------------------------
99
209
  // Token recording
100
210
  // -----------------------------------------------------------------
@@ -133,9 +243,27 @@ class CostController extends EventEmitter {
133
243
  // Update global total
134
244
  this._state.totalTokens += tokenCount;
135
245
 
246
+ // Org/tenant rollup (only when an org ceiling is configured). The rollup
247
+ // aggregates this project's spend into the org's running total and records
248
+ // which projects contribute, so operators can see cross-project spend.
249
+ if (this._orgConfig) {
250
+ if (!this._state.orgs) this._state.orgs = {};
251
+ const orgId = this._orgId || 'default';
252
+ if (!this._state.orgs[orgId]) {
253
+ this._state.orgs[orgId] = { totalTokens: 0, projects: {} };
254
+ }
255
+ const org = this._state.orgs[orgId];
256
+ org.totalTokens += tokenCount;
257
+ const pid = projectId || 'unknown';
258
+ org.projects[pid] = (org.projects[pid] || 0) + tokenCount;
259
+ }
260
+
136
261
  // Check alerts and budget
137
262
  this._checkAlerts(projectId);
138
263
 
264
+ // Check org ceiling (no-op when org governance is off)
265
+ this._checkOrgCeiling();
266
+
139
267
  this._saveState();
140
268
  }
141
269
 
@@ -183,6 +311,131 @@ class CostController extends EventEmitter {
183
311
  return { remaining, percentage, alerts, exceeded };
184
312
  }
185
313
 
314
+ /**
315
+ * Check the aggregate org/tenant budget status.
316
+ *
317
+ * Rolls up spend across every project attributed to the org and compares it
318
+ * against the configured org ceiling. When no org ceiling is configured this
319
+ * returns an unlimited, never-exceeded result so callers can treat the org
320
+ * gate as transparently absent.
321
+ *
322
+ * @param {string} [orgId] - Org identifier (defaults to the auto-detected org)
323
+ * @returns {{ orgId: (string|null), consumed: number, remaining: number,
324
+ * percentage: number, alerts: Array, exceeded: boolean,
325
+ * decision: string }}
326
+ */
327
+ checkOrgBudget(orgId) {
328
+ if (!this._orgConfig) {
329
+ return {
330
+ orgId: null,
331
+ consumed: 0,
332
+ remaining: Infinity,
333
+ percentage: 0,
334
+ alerts: [],
335
+ exceeded: false,
336
+ decision: 'allow',
337
+ };
338
+ }
339
+
340
+ const id = orgId || this._orgId || 'default';
341
+ const org = this._state.orgs && this._state.orgs[id];
342
+ const consumed = org ? org.totalTokens : 0;
343
+
344
+ const max = this._orgConfig.maxTokens;
345
+ const percentage = max > 0 ? Math.round((consumed / max) * 100) : 0;
346
+ const remaining = Math.max(0, max - consumed);
347
+ const exceeded = consumed >= max;
348
+
349
+ const alerts = [];
350
+ const thresholds = this._orgConfig.alerts;
351
+ for (let i = 0; i < thresholds.length; i++) {
352
+ if (percentage >= thresholds[i]) {
353
+ alerts.push({
354
+ threshold: thresholds[i],
355
+ message: 'Org token usage at ' + percentage + '% (threshold: ' + thresholds[i] + '%)',
356
+ });
357
+ }
358
+ }
359
+
360
+ // Decision: 'warn' never blocks; 'pause'/'shutdown' deny once exceeded.
361
+ let decision = 'allow';
362
+ if (exceeded && this._orgConfig.onExceed !== 'warn') {
363
+ decision = this._orgConfig.onExceed === 'shutdown' ? 'deny' : 'pause';
364
+ }
365
+
366
+ return { orgId: id, consumed, remaining, percentage, alerts, exceeded, decision };
367
+ }
368
+
369
+ _checkOrgCeiling() {
370
+ if (!this._orgConfig) return;
371
+
372
+ const orgId = this._orgId || 'default';
373
+ const budget = this.checkOrgBudget(orgId);
374
+
375
+ // Emit alert events for newly triggered org thresholds (deduped per org).
376
+ const thresholds = this._orgConfig.alerts;
377
+ for (let i = 0; i < thresholds.length; i++) {
378
+ const key = 'org:' + orgId + ':' + thresholds[i];
379
+ if (budget.percentage >= thresholds[i] && !this._triggeredAlerts.has(key)) {
380
+ this._triggeredAlerts.add(key);
381
+
382
+ this._pushHistory({
383
+ type: 'org_alert',
384
+ threshold: thresholds[i],
385
+ percentage: budget.percentage,
386
+ orgId: orgId,
387
+ consumed: budget.consumed,
388
+ max: this._orgConfig.maxTokens,
389
+ timestamp: new Date().toISOString(),
390
+ });
391
+
392
+ this.emit('org_alert', {
393
+ threshold: thresholds[i],
394
+ percentage: budget.percentage,
395
+ orgId: orgId,
396
+ remaining: budget.remaining,
397
+ });
398
+ }
399
+ }
400
+
401
+ // Ceiling enforcement: pause/deny when the org aggregate is exceeded.
402
+ // 'warn' is advisory and never blocks. Each org enforces at most once.
403
+ if (budget.exceeded
404
+ && this._orgConfig.onExceed !== 'warn'
405
+ && !this._shutdownEmittedOrgs.has(orgId)) {
406
+ this._shutdownEmittedOrgs.add(orgId);
407
+
408
+ this._pushHistory({
409
+ type: 'org_ceiling_exceeded',
410
+ decision: budget.decision,
411
+ reason: 'Org budget exceeded',
412
+ percentage: budget.percentage,
413
+ orgId: orgId,
414
+ consumed: budget.consumed,
415
+ max: this._orgConfig.maxTokens,
416
+ timestamp: new Date().toISOString(),
417
+ });
418
+
419
+ this._saveState();
420
+ this.emit('org_ceiling', {
421
+ reason: 'Org token budget exceeded',
422
+ decision: budget.decision,
423
+ orgId: orgId,
424
+ percentage: budget.percentage,
425
+ consumed: budget.consumed,
426
+ max: this._orgConfig.maxTokens,
427
+ });
428
+ }
429
+ }
430
+
431
+ /** Append a history record with bounded growth. */
432
+ _pushHistory(record) {
433
+ if (this._state.history.length > MAX_STATE_ENTRIES) {
434
+ this._state.history.splice(0, this._state.history.length - MAX_STATE_ENTRIES);
435
+ }
436
+ this._state.history.push(record);
437
+ }
438
+
186
439
  _checkAlerts(projectId) {
187
440
  if (!this._budgetConfig) return;
188
441
 
@@ -265,6 +518,20 @@ class CostController extends EventEmitter {
265
518
  return Object.assign({}, this._state.projects);
266
519
  }
267
520
 
521
+ /**
522
+ * Get the org/tenant rollup report.
523
+ *
524
+ * @param {string} [orgId] - When given, returns that org's rollup (or null);
525
+ * otherwise returns a copy of all org rollups.
526
+ */
527
+ getOrgReport(orgId) {
528
+ const orgs = this._state.orgs || {};
529
+ if (orgId) {
530
+ return orgs[orgId] || null;
531
+ }
532
+ return Object.assign({}, orgs);
533
+ }
534
+
268
535
  /**
269
536
  * Get the history of alerts and shutdown events.
270
537
  */
@@ -279,12 +546,14 @@ class CostController extends EventEmitter {
279
546
  this._state = {
280
547
  projects: {},
281
548
  agents: {},
549
+ orgs: {},
282
550
  totalTokens: 0,
283
551
  triggeredAlerts: [],
284
552
  history: [],
285
553
  };
286
554
  this._triggeredAlerts.clear();
287
555
  this._shutdownEmittedProjects.clear();
556
+ this._shutdownEmittedOrgs.clear();
288
557
  this._saveState();
289
558
  }
290
559
  }