@schlessera/brain-ui-server 0.19.0 → 0.21.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.
Files changed (78) hide show
  1. package/README.md +5 -1
  2. package/data/model-prices.json +63 -0
  3. package/dist/activity/digest.d.ts.map +1 -1
  4. package/dist/activity/digest.js +30 -2
  5. package/dist/activity/digest.js.map +1 -1
  6. package/dist/activity/notify.d.ts.map +1 -1
  7. package/dist/activity/notify.js +26 -4
  8. package/dist/activity/notify.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +28 -1
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +5 -0
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +37 -4
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts +7 -1
  17. package/dist/activity/runtime.d.ts.map +1 -1
  18. package/dist/activity/runtime.js +28 -10
  19. package/dist/activity/runtime.js.map +1 -1
  20. package/dist/activity/store.d.ts +51 -2
  21. package/dist/activity/store.d.ts.map +1 -1
  22. package/dist/activity/store.js +236 -19
  23. package/dist/activity/store.js.map +1 -1
  24. package/dist/activity/stream.d.ts +6 -2
  25. package/dist/activity/stream.d.ts.map +1 -1
  26. package/dist/activity/stream.js +42 -1
  27. package/dist/activity/stream.js.map +1 -1
  28. package/dist/agent/backend.d.ts +13 -4
  29. package/dist/agent/backend.d.ts.map +1 -1
  30. package/dist/agent/backend.js +68 -5
  31. package/dist/agent/backend.js.map +1 -1
  32. package/dist/app.d.ts.map +1 -1
  33. package/dist/app.js +18 -3
  34. package/dist/app.js.map +1 -1
  35. package/dist/config/env.d.ts +36 -0
  36. package/dist/config/env.d.ts.map +1 -1
  37. package/dist/config/env.js +66 -2
  38. package/dist/config/env.js.map +1 -1
  39. package/dist/db/settings.d.ts +15 -0
  40. package/dist/db/settings.d.ts.map +1 -1
  41. package/dist/db/settings.js +43 -0
  42. package/dist/db/settings.js.map +1 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pricing/model-pricing.d.ts +61 -0
  47. package/dist/pricing/model-pricing.d.ts.map +1 -0
  48. package/dist/pricing/model-pricing.js +379 -0
  49. package/dist/pricing/model-pricing.js.map +1 -0
  50. package/dist/routes/activity.d.ts.map +1 -1
  51. package/dist/routes/activity.js +40 -32
  52. package/dist/routes/activity.js.map +1 -1
  53. package/dist/routes/models.d.ts +6 -0
  54. package/dist/routes/models.d.ts.map +1 -1
  55. package/dist/routes/models.js +38 -3
  56. package/dist/routes/models.js.map +1 -1
  57. package/dist/ws/run-session.d.ts.map +1 -1
  58. package/dist/ws/run-session.js +36 -2
  59. package/dist/ws/run-session.js.map +1 -1
  60. package/migrations/009_activity_followups.sql +22 -0
  61. package/migrations/010_effective_cost.sql +21 -0
  62. package/package.json +4 -3
  63. package/src/activity/digest.ts +30 -2
  64. package/src/activity/notify.ts +30 -8
  65. package/src/activity/query.ts +28 -1
  66. package/src/activity/recorder.ts +57 -5
  67. package/src/activity/runtime.ts +41 -13
  68. package/src/activity/store.ts +316 -21
  69. package/src/activity/stream.ts +39 -9
  70. package/src/agent/backend.ts +91 -10
  71. package/src/app.ts +19 -3
  72. package/src/config/env.ts +90 -2
  73. package/src/db/settings.ts +56 -0
  74. package/src/index.ts +1 -0
  75. package/src/pricing/model-pricing.ts +497 -0
  76. package/src/routes/activity.ts +41 -33
  77. package/src/routes/models.ts +57 -6
  78. package/src/ws/run-session.ts +45 -3
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from "@opentelemetry/api-logs";
2
2
  import { createRequire } from "module";
3
3
  import type { AgentConfig } from "../config/env.js";
4
- import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
4
+ import type { BillingMode, ProviderInfo } from "@schlessera/brain-ui-sdk";
5
5
  import type {
6
6
  AgentBackend,
7
7
  BackendCapabilities,
@@ -175,6 +175,12 @@ export interface BackendRegistryOptions {
175
175
  * sessions pinned to it. Defaults to "nothing hidden".
176
176
  */
177
177
  getHiddenModelIds?: () => string[];
178
+ /**
179
+ * Per-profile billing-mode overrides (from the app's settings table).
180
+ * Consulted LAST: an override wins over both the declared-credential rule
181
+ * and the ambient predicate. Defaults to "no overrides".
182
+ */
183
+ getBillingOverrides?: () => Record<string, BillingMode>;
178
184
  /**
179
185
  * Where the registry and its backends report. Adapted to the backends'
180
186
  * minimal callback ({@link BackendLogFn}) before crossing the package
@@ -197,9 +203,11 @@ export interface BackendRegistry {
197
203
  /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
198
204
  getBackendForSession(backendId: string | null | undefined): Promise<AgentBackend>;
199
205
  /**
200
- * Every available profile, tagged with its owning backend id. Hidden
201
- * profiles are omitted by default (this feeds the picker); the settings
202
- * screen passes `includeHidden` to render the full catalog.
206
+ * Every available profile, tagged with its owning backend id and (when the
207
+ * registry can classify it) its resolved `billingMode` settings override
208
+ * applied last. Hidden profiles are omitted by default (this feeds the
209
+ * picker); the settings screen passes `includeHidden` to render the full
210
+ * catalog.
203
211
  */
204
212
  listAllProviders(options?: { includeHidden?: boolean }): Promise<ProviderInfo[]>;
205
213
  /** Per-backend capability metadata exposed by the providers route. */
@@ -372,6 +380,32 @@ export function createBackendRegistry(
372
380
  let cachedRegistry: Promise<RegistrySnapshot> | null = null;
373
381
  let modelSource: ClaudeModelSource | null = null;
374
382
 
383
+ /**
384
+ * Declared profiles carrying their OWN credential env vars
385
+ * (authTokenEnv/apiKeyEnv) — api-billed regardless of the ambient
386
+ * credential. Populated when the Claude roster resolves, which happens
387
+ * before any profile can be listed or run.
388
+ */
389
+ const declaredApiProfileIds = new Set<string>();
390
+ /** The Claude backend's id, once built — profiles on any OTHER backend (pi) have no subscription path. */
391
+ let claudeBackendId: string | null = null;
392
+
393
+ /**
394
+ * Base billing classification for one roster entry, BEFORE the settings
395
+ * override (applied last by the shared accessor surface):
396
+ * - a non-Claude backend (pi) → "api";
397
+ * - a declared profile with explicit credentials → "api";
398
+ * - everything ambient (built-in default, discovered models, declared
399
+ * entries without their own credentials) → the env-resolved ambient
400
+ * mode (subscription iff the OAuth token is present and no
401
+ * ANTHROPIC_API_KEY — the Agent SDK's own precedence).
402
+ */
403
+ function classifyBilling(profile: ProviderInfo & { backendId: string }): BillingMode {
404
+ if (claudeBackendId === null || profile.backendId !== claudeBackendId) return "api";
405
+ if (declaredApiProfileIds.has(profile.id)) return "api";
406
+ return agent.ambientBilling;
407
+ }
408
+
375
409
  /**
376
410
  * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
377
411
  * discovered model whose id isn't already declared. Declared wins: the env
@@ -469,6 +503,9 @@ export function createBackendRegistry(
469
503
  );
470
504
  }
471
505
  seen.add(input.id);
506
+ // The same non-empty test defineProfiles applies to requiredEnvKeys: a
507
+ // profile bringing its own credential env var is api-billed.
508
+ if (input.authTokenEnv || input.apiKeyEnv) declaredApiProfileIds.add(input.id);
472
509
  }
473
510
 
474
511
  const declared = (inputs as ClaudeProfileInput[]).map((input) => ({
@@ -503,7 +540,7 @@ export function createBackendRegistry(
503
540
  ttlMs: agent.modelTtlMs,
504
541
  });
505
542
 
506
- return claude.createClaudeBackend({
543
+ const backend = claude.createClaudeBackend({
507
544
  brainPath,
508
545
  claudeCodePath: agent.claudeCodePath,
509
546
  ...(backendLog ? { log: backendLog } : {}),
@@ -517,6 +554,8 @@ export function createBackendRegistry(
517
554
  profiles: () =>
518
555
  mergeDiscovered(claude, declared, modelSource?.list() ?? []),
519
556
  });
557
+ claudeBackendId = backend.id;
558
+ return backend;
520
559
  }
521
560
 
522
561
  async function buildPiBackend(): Promise<AgentBackend> {
@@ -566,7 +605,13 @@ export function createBackendRegistry(
566
605
  await getRegistry();
567
606
  return modelSource;
568
607
  },
569
- options.log
608
+ options.log,
609
+ {
610
+ classify: classifyBilling,
611
+ ...(options.getBillingOverrides
612
+ ? { getOverrides: options.getBillingOverrides }
613
+ : {}),
614
+ }
570
615
  );
571
616
  }
572
617
 
@@ -579,14 +624,23 @@ export function createBackendRegistry(
579
624
  export function createStaticBackendRegistry(
580
625
  backends: AgentBackend[],
581
626
  defaultBackendId = backends[0]?.id ?? "",
582
- options: { getHiddenModelIds?: () => string[]; log?: Logger } = {}
627
+ options: {
628
+ getHiddenModelIds?: () => string[];
629
+ getBillingOverrides?: () => Record<string, BillingMode>;
630
+ log?: Logger;
631
+ } = {}
583
632
  ): BackendRegistry {
584
633
  const snapshot = buildSnapshot(backends, defaultBackendId);
585
634
  return makeRegistry(
586
635
  async () => snapshot,
587
636
  options.getHiddenModelIds ?? (() => []),
588
637
  async () => null,
589
- options.log
638
+ options.log,
639
+ // No classifier: an embedder's backends carry no credential topology this
640
+ // registry could reason about, so only an explicit override sets a mode.
641
+ options.getBillingOverrides
642
+ ? { getOverrides: options.getBillingOverrides }
643
+ : {}
590
644
  );
591
645
  }
592
646
 
@@ -595,7 +649,13 @@ function makeRegistry(
595
649
  getRegistry: () => Promise<RegistrySnapshot>,
596
650
  getHidden: () => string[],
597
651
  getModelSource: () => Promise<ModelDiscoverySource | null>,
598
- log?: Logger
652
+ log?: Logger,
653
+ billing: {
654
+ /** Base classification per roster entry; absent = the registry cannot classify. */
655
+ classify?: (profile: ProviderInfo & { backendId: string }) => BillingMode;
656
+ /** Settings overrides, consulted LAST — an override wins over `classify`. */
657
+ getOverrides?: () => Record<string, BillingMode>;
658
+ } = {}
599
659
  ): BackendRegistry {
600
660
  let profileSnapshot: ProfileSnapshot | null = null;
601
661
 
@@ -641,6 +701,20 @@ function makeRegistry(
641
701
  }
642
702
  }
643
703
 
704
+ /** Same degradation discipline: a failed override read means derived modes apply. */
705
+ function billingOverrides(): Record<string, BillingMode> {
706
+ try {
707
+ return billing.getOverrides?.() ?? {};
708
+ } catch (err) {
709
+ log?.emit({
710
+ severityText: "WARN",
711
+ body: "could not read billing overrides; using derived billing modes",
712
+ attributes: { error: err instanceof Error ? err.message : String(err) },
713
+ });
714
+ return {};
715
+ }
716
+ }
717
+
644
718
  return {
645
719
  async getBackends() {
646
720
  return (await getRegistry()).backends;
@@ -681,11 +755,18 @@ function makeRegistry(
681
755
  const registry = await getRegistry();
682
756
  const snapshot = await getProfileSnapshot();
683
757
  const hidden = options.includeHidden ? new Set<string>() : hiddenIds();
758
+ // Read fresh on every listing (not memoized with the snapshot), so a
759
+ // saved override is live for the very next run without invalidation.
760
+ const overrides = billingOverrides();
684
761
 
685
762
  return registry.backends.flatMap((backend) =>
686
763
  (snapshot.byBackend.get(backend.id) ?? [])
687
764
  .filter((profile) => !hidden.has(profile.id))
688
- .map((profile) => ({ ...profile, backendId: backend.id }))
765
+ .map((profile) => {
766
+ const entry = { ...profile, backendId: backend.id };
767
+ const billingMode = overrides[profile.id] ?? billing.classify?.(entry);
768
+ return billingMode ? { ...entry, billingMode } : entry;
769
+ })
689
770
  );
690
771
  },
691
772
 
package/src/app.ts CHANGED
@@ -33,8 +33,9 @@ import {
33
33
  type PasskeyContext,
34
34
  } from "./middleware/passkeys.js";
35
35
  import { createUiDb } from "./db/client.js";
36
- import { getHiddenModelIds } from "./db/settings.js";
36
+ import { getBillingOverrides, getHiddenModelIds } from "./db/settings.js";
37
37
  import { createActivityRuntime } from "./activity/runtime.js";
38
+ import { createModelPricing } from "./pricing/model-pricing.js";
38
39
  import { createPushRoutes } from "./routes/push.js";
39
40
  import {
40
41
  assertBackendResolvable,
@@ -175,15 +176,30 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
175
176
  const brain = createBrainClient({ brainPath: config.brainPath });
176
177
  const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
177
178
 
179
+ // Model pricing for rollup-time effective cost: constructed here because
180
+ // the config owns enabled/TTL/brainPath, shared through the activity
181
+ // runtime. The first refresh warms in the background — ensureFresh never
182
+ // rejects and no rollup ever waits on the network (resolve() is sync).
183
+ const pricing = createModelPricing({
184
+ brainPath: config.brainPath,
185
+ enabled: config.pricing.enabled,
186
+ ttlMs: config.pricing.ttlMs,
187
+ });
188
+ void pricing.ensureFresh();
189
+
178
190
  // Activity record: span store + live stream + notifications + lifecycle
179
191
  // sweeps, owned by the runtime (see activity/runtime.ts).
180
- const activity = createActivityRuntime(db, { log: observability.logger("activity") });
192
+ const activity = createActivityRuntime(db, {
193
+ log: observability.logger("activity"),
194
+ pricing,
195
+ });
181
196
  const registry =
182
197
  options.registry ??
183
198
  createBackendRegistry({
184
199
  brainPath: config.brainPath,
185
200
  agent: config.agent,
186
201
  getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
202
+ getBillingOverrides: () => getBillingOverrides(db, dbLog),
187
203
  log: observability.logger("agent"),
188
204
  });
189
205
  const host = new WsHost({
@@ -326,7 +342,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
326
342
  app.route("/api", createProviderRoutes({ registry }));
327
343
  app.route(
328
344
  "/api",
329
- createModelRoutes({ registry, db, log: observability.logger("models") })
345
+ createModelRoutes({ registry, db, pricing, log: observability.logger("models") })
330
346
  );
331
347
  app.route(
332
348
  "/api",
package/src/config/env.ts CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  import { join } from "path";
17
17
 
18
+ import type { BillingMode } from "@schlessera/brain-ui-sdk/protocol";
19
+
18
20
  import { SEVERITIES, type Severity } from "../observability/types.js";
19
21
  import { envFlag } from "./env-core.js";
20
22
 
@@ -257,6 +259,25 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
257
259
  default: "(none)",
258
260
  required: false,
259
261
  },
262
+ {
263
+ name: "CLAUDE_CODE_OAUTH_TOKEN",
264
+ description:
265
+ "Consulted for PRESENCE only, to classify billing: with it set and no " +
266
+ "ANTHROPIC_API_KEY, ambient-credential Claude profiles (the built-in " +
267
+ "default and discovered models) count as subscription-billed. The token " +
268
+ "itself is consumed by the Claude backend / Agent SDK, not this package.",
269
+ default: null,
270
+ required: false,
271
+ },
272
+ {
273
+ name: "ANTHROPIC_API_KEY",
274
+ description:
275
+ "Consulted for PRESENCE only, to classify billing: when set it wins " +
276
+ "over CLAUDE_CODE_OAUTH_TOKEN (mirroring the Agent SDK's credential " +
277
+ "precedence), so ambient-credential profiles count as api-billed.",
278
+ default: null,
279
+ required: false,
280
+ },
260
281
  {
261
282
  name: "BRAIN_UI_MODEL_DISCOVERY",
262
283
  description:
@@ -270,10 +291,23 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
270
291
  default: "24",
271
292
  required: false,
272
293
  },
294
+ {
295
+ name: "BRAIN_UI_PRICING_DISCOVERY",
296
+ description:
297
+ 'Remote model-pricing refresh (LiteLLM + OpenRouter catalogs); "0"/"off"/"false" disables, and runs then roll up with unknown effective cost. Defaults ON, except under a test runner (NODE_ENV=test) where it defaults OFF.',
298
+ default: "on (off under NODE_ENV=test)",
299
+ required: false,
300
+ },
301
+ {
302
+ name: "BRAIN_UI_PRICING_TTL_HOURS",
303
+ description: "How long a fetched model-pricing table stays fresh, in hours.",
304
+ default: "24",
305
+ required: false,
306
+ },
273
307
  {
274
308
  name: "NODE_ENV",
275
309
  description:
276
- "Only consulted for test-runner detection: flips the model-discovery default to off under bun test. Never gates any security behavior.",
310
+ "Only consulted for test-runner detection: flips the model-discovery and pricing-discovery defaults to off under bun test. Never gates any security behavior.",
277
311
  default: "(unset)",
278
312
  required: false,
279
313
  },
@@ -347,6 +381,15 @@ export interface AgentConfig {
347
381
  profilesJson: string | null;
348
382
  modelDiscovery: boolean;
349
383
  modelTtlMs: number;
384
+ /**
385
+ * Billing classification for profiles running on AMBIENT credentials (the
386
+ * built-in default and discovered models): "subscription" iff the
387
+ * environment holds a CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY —
388
+ * the same precedence the Agent SDK applies — else "api". Declared profiles
389
+ * carrying their own credential env vars are classified "api" by the
390
+ * registry regardless of this value.
391
+ */
392
+ ambientBilling: BillingMode;
350
393
  }
351
394
 
352
395
  export interface VoiceConfig {
@@ -380,6 +423,8 @@ export interface ServerConfig {
380
423
  webauthn: WebAuthnConfig;
381
424
  agent: AgentConfig;
382
425
  voice: VoiceConfig;
426
+ /** Model-pricing service (BRAIN_UI_PRICING_*); inline like wsRate. */
427
+ pricing: { enabled: boolean; ttlMs: number };
383
428
  }
384
429
 
385
430
  // --- resolver ----------------------------------------------------------------
@@ -438,12 +483,50 @@ function parseSeverity(raw: string | undefined): Severity {
438
483
 
439
484
  const AUTH_MODES: readonly AuthModeName[] = ["password", "tailscale", "proxy", "none"];
440
485
 
486
+ /**
487
+ * Ambient billing classification from an environment (presence-only reads).
488
+ * The API key wins over the OAuth token — the Agent SDK's own precedence —
489
+ * and no usable credential at all classifies "api" (nothing
490
+ * subscription-billed can run without the token). Exported for the activity
491
+ * store's standalone default, so the cron wrapper classifies from the same
492
+ * predicate the server config does.
493
+ */
494
+ export function resolveAmbientBillingMode(env: EnvRecord = process.env): BillingMode {
495
+ return !env.ANTHROPIC_API_KEY?.trim() && env.CLAUDE_CODE_OAUTH_TOKEN?.trim()
496
+ ? "subscription"
497
+ : "api";
498
+ }
499
+
500
+ /**
501
+ * THE derivation of the pricing config (kill switch, TTL, brain path) —
502
+ * consumed by resolveServerConfig and used directly by the cron wrapper's
503
+ * bare `createActivityStore(db)` path, where no ServerConfig exists. Kept
504
+ * here so process.env reads stay in the one chokepoint the env-access gate
505
+ * allows.
506
+ */
507
+ export function resolveStandalonePricingConfig(env: EnvRecord = process.env): {
508
+ brainPath: string;
509
+ enabled: boolean;
510
+ ttlMs: number;
511
+ } {
512
+ const ttlHours = Number(env.BRAIN_UI_PRICING_TTL_HOURS);
513
+ return {
514
+ brainPath: env.BRAIN_PATH || join(env.HOME || "/root", "brain"),
515
+ enabled: envFlag(env.BRAIN_UI_PRICING_DISCOVERY, env.NODE_ENV !== "test"),
516
+ ttlMs:
517
+ Number.isFinite(ttlHours) && ttlHours > 0
518
+ ? ttlHours * 60 * 60 * 1000
519
+ : 24 * 60 * 60 * 1000,
520
+ };
521
+ }
522
+
441
523
  /**
442
524
  * Resolve an environment into a {@link ServerConfig}. Defaults to the real
443
525
  * process environment; tests pass their own record instead of mutating it.
444
526
  */
445
527
  export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig {
446
- const brainPath = env.BRAIN_PATH || join(env.HOME || "/root", "brain");
528
+ const { brainPath, enabled: pricingEnabled, ttlMs: pricingTtlMs } =
529
+ resolveStandalonePricingConfig(env);
447
530
 
448
531
  const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
449
532
  const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
@@ -494,6 +577,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
494
577
  profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
495
578
  modelDiscovery,
496
579
  modelTtlMs: ttlHours * 60 * 60 * 1000,
580
+ ambientBilling: resolveAmbientBillingMode(env),
497
581
  },
498
582
  logLevel: parseSeverity(env.BRAIN_UI_LOG_LEVEL),
499
583
  wsRate: {
@@ -506,6 +590,10 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
506
590
  keytermLimit: Number(env.VOICE_KEYTERM_LIMIT || 500),
507
591
  cacheDir: env.VOICE_CACHE_DIR || join(brainPath, ".brain-ui"),
508
592
  },
593
+ pricing: {
594
+ enabled: pricingEnabled,
595
+ ttlMs: pricingTtlMs,
596
+ },
509
597
  };
510
598
  }
511
599
 
@@ -8,8 +8,12 @@
8
8
 
9
9
  import type { Logger } from "@opentelemetry/api-logs";
10
10
  import type { Database } from "bun:sqlite";
11
+ import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protocol";
11
12
 
12
13
  const HIDDEN_MODELS_KEY = "models.hidden";
14
+ const BILLING_OVERRIDES_KEY = "models.billing";
15
+ const DETAIL_RETENTION_KEY = "activity.retention.detailDays";
16
+ const DETAIL_RETENTION_DEFAULT_DAYS = 7;
13
17
 
14
18
  export function getSetting<T>(db: Database, key: string, fallback: T, log?: Logger): T {
15
19
  const row = db
@@ -47,3 +51,55 @@ export function setHiddenModelIds(db: Database, ids: string[]): void {
47
51
  const unique = [...new Set(ids.filter((id) => typeof id === "string" && id))];
48
52
  setSetting(db, HIDDEN_MODELS_KEY, unique);
49
53
  }
54
+
55
+ /**
56
+ * Per-profile billing-mode overrides: profile id → forced classification. A
57
+ * profile absent from the record is "auto" (the registry's derived mode
58
+ * applies). Anything that is not exactly "subscription" or "api" is dropped
59
+ * on read, so a corrupt row degrades to auto rather than misbilling.
60
+ */
61
+ export function getBillingOverrides(
62
+ db: Database,
63
+ log?: Logger
64
+ ): Record<string, BillingMode> {
65
+ const value = getSetting<unknown>(db, BILLING_OVERRIDES_KEY, {}, log);
66
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
67
+ return Object.create(null);
68
+ }
69
+ // Null-prototype: profile ids are user-supplied strings — an id like
70
+ // "__proto__" must be an ordinary key, and a "constructor" lookup by a
71
+ // consumer must not resolve a prototype member.
72
+ const overrides: Record<string, BillingMode> = Object.create(null);
73
+ for (const [profileId, mode] of Object.entries(value)) {
74
+ if (isBillingMode(mode)) overrides[profileId] = mode;
75
+ }
76
+ return overrides;
77
+ }
78
+
79
+ /** Replace the override record (the client always sends the full record, not a delta). */
80
+ export function setBillingOverrides(
81
+ db: Database,
82
+ overrides: Record<string, BillingMode>
83
+ ): void {
84
+ const clean = Object.fromEntries(
85
+ Object.entries(overrides).filter(
86
+ ([profileId, mode]) => profileId && isBillingMode(mode)
87
+ )
88
+ );
89
+ setSetting(db, BILLING_OVERRIDES_KEY, clean);
90
+ }
91
+
92
+ /** Minimum days a finished run keeps its detail (spans/events) before the
93
+ * digest-covered prune may take it. 0 is valid (prune as soon as covered);
94
+ * anything non-numeric or negative degrades to the default. */
95
+ export function getDetailRetentionDays(db: Database, log?: Logger): number {
96
+ const value = getSetting<unknown>(db, DETAIL_RETENTION_KEY, DETAIL_RETENTION_DEFAULT_DAYS, log);
97
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
98
+ return DETAIL_RETENTION_DEFAULT_DAYS;
99
+ }
100
+ return value;
101
+ }
102
+
103
+ export function setDetailRetentionDays(db: Database, days: number): void {
104
+ setSetting(db, DETAIL_RETENTION_KEY, days);
105
+ }
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ export {
92
92
  type SpanOutcome,
93
93
  type SpanUsage,
94
94
  type ActivityChange,
95
+ type RollupPricing,
95
96
  } from "./activity/store.js";
96
97
  export { createActivityStream, type ActivityStream } from "./activity/stream.js";
97
98
  export { ingestSpanSink } from "./activity/span-sink.js";