@schlessera/brain-ui-server 0.19.0 → 0.20.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/README.md +5 -1
- package/data/model-prices.json +63 -0
- package/dist/activity/digest.d.ts.map +1 -1
- package/dist/activity/digest.js +30 -2
- package/dist/activity/digest.js.map +1 -1
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +26 -4
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +28 -1
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +5 -0
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +37 -4
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts +7 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +28 -10
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/store.d.ts +51 -2
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +236 -19
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +6 -2
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +42 -1
- package/dist/activity/stream.js.map +1 -1
- package/dist/agent/backend.d.ts +13 -4
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +68 -5
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +18 -3
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +36 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +66 -2
- package/dist/config/env.js.map +1 -1
- package/dist/db/settings.d.ts +15 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +43 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pricing/model-pricing.d.ts +61 -0
- package/dist/pricing/model-pricing.d.ts.map +1 -0
- package/dist/pricing/model-pricing.js +379 -0
- package/dist/pricing/model-pricing.js.map +1 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +40 -32
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/models.d.ts +6 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +38 -3
- package/dist/routes/models.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +36 -2
- package/dist/ws/run-session.js.map +1 -1
- package/migrations/009_activity_followups.sql +22 -0
- package/migrations/010_effective_cost.sql +21 -0
- package/package.json +4 -3
- package/src/activity/digest.ts +30 -2
- package/src/activity/notify.ts +30 -8
- package/src/activity/query.ts +28 -1
- package/src/activity/recorder.ts +57 -5
- package/src/activity/runtime.ts +41 -13
- package/src/activity/store.ts +316 -21
- package/src/activity/stream.ts +39 -9
- package/src/agent/backend.ts +91 -10
- package/src/app.ts +19 -3
- package/src/config/env.ts +90 -2
- package/src/db/settings.ts +56 -0
- package/src/index.ts +1 -0
- package/src/pricing/model-pricing.ts +497 -0
- package/src/routes/activity.ts +41 -33
- package/src/routes/models.ts +57 -6
- package/src/ws/run-session.ts +45 -3
package/src/agent/backend.ts
CHANGED
|
@@ -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
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
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: {
|
|
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) =>
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
package/src/db/settings.ts
CHANGED
|
@@ -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";
|