@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.
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
@@ -8,27 +8,45 @@
8
8
  import type { Logger } from "@opentelemetry/api-logs";
9
9
  import { Hono } from "hono";
10
10
  import type { Database } from "bun:sqlite";
11
- import type {
12
- ModelCatalogEntry,
13
- ModelCatalogResponse,
11
+ import {
12
+ isBillingMode,
13
+ type BillingMode,
14
+ type ModelCatalogEntry,
15
+ type ModelCatalogResponse,
14
16
  } from "@schlessera/brain-ui-sdk";
15
17
  import type { BackendRegistry } from "../agent/backend.js";
16
- import { getHiddenModelIds, setHiddenModelIds } from "../db/settings.js";
18
+ import type { ModelPricingState } from "../pricing/model-pricing.js";
19
+ import {
20
+ getBillingOverrides,
21
+ getHiddenModelIds,
22
+ setBillingOverrides,
23
+ setHiddenModelIds,
24
+ } from "../db/settings.js";
17
25
 
18
26
  export function createModelRoutes(deps: {
19
27
  registry: BackendRegistry;
20
28
  db: Database;
29
+ /** The shared pricing instance — its state drives the client's staleness indicator. */
30
+ pricing?: { state(): ModelPricingState; ensureFresh(): Promise<void> };
21
31
  /** Where refresh failures are reported. */
22
32
  log?: Logger;
23
33
  }): Hono {
24
- const { registry, db } = deps;
34
+ const { registry, db, pricing } = deps;
25
35
 
26
36
  async function buildCatalog(): Promise<ModelCatalogResponse> {
27
37
  const source = await registry.getModelSource();
28
38
  const hidden = new Set(getHiddenModelIds(db));
39
+ const overrides = getBillingOverrides(db);
40
+ // `billingMode` already rides each provider entry (the registry applies the
41
+ // override last); the catalog additionally tags WHICH rows carry an explicit
42
+ // override, so the settings screen can render auto vs forced.
29
43
  const models: ModelCatalogEntry[] = (
30
44
  await registry.listAllProviders({ includeHidden: true })
31
- ).map((profile) => ({ ...profile, hidden: hidden.has(profile.id) }));
45
+ ).map((profile) => ({
46
+ ...profile,
47
+ hidden: hidden.has(profile.id),
48
+ ...(overrides[profile.id] ? { billingOverride: overrides[profile.id] } : {}),
49
+ }));
32
50
 
33
51
  const state = source?.state();
34
52
  return {
@@ -51,6 +69,17 @@ export function createModelRoutes(deps: {
51
69
  return c.json(await buildCatalog());
52
70
  })
53
71
 
72
+ .get("/models/pricing", async (c) => {
73
+ // Absent instance (a host that opted out) → 404; the client hides the
74
+ // indicator on any rejection, so old and opted-out servers degrade alike.
75
+ if (!pricing) return c.json({ error: "pricing not available" }, 404);
76
+ // Kick a refresh behind the response when stale — reading the state is
77
+ // also the natural moment to heal it. Never blocks: ensureFresh awaits
78
+ // only on a true cold start.
79
+ void pricing.ensureFresh().catch(() => {});
80
+ return c.json(pricing.state());
81
+ })
82
+
54
83
  .put("/models/hidden", async (c) => {
55
84
  const body = (await c.req.json().catch(() => null)) as unknown;
56
85
  const hidden = (body as { hidden?: unknown } | null)?.hidden;
@@ -67,6 +96,28 @@ export function createModelRoutes(deps: {
67
96
  return c.json(await buildCatalog());
68
97
  })
69
98
 
99
+ .put("/models/billing", async (c) => {
100
+ const body = (await c.req.json().catch(() => null)) as unknown;
101
+ const billing = (body as { billing?: unknown } | null)?.billing;
102
+ if (
103
+ typeof billing !== "object" ||
104
+ billing === null ||
105
+ Array.isArray(billing) ||
106
+ Object.values(billing).some((mode) => !isBillingMode(mode))
107
+ ) {
108
+ return c.json(
109
+ { error: 'billing must map profile ids to "subscription" or "api"' },
110
+ 400
111
+ );
112
+ }
113
+
114
+ setBillingOverrides(db, billing as Record<string, BillingMode>);
115
+ // Same memo discipline as the hidden set: the next roster read (and the
116
+ // next run's classification) must see the override immediately.
117
+ registry.invalidateProfiles();
118
+ return c.json(await buildCatalog());
119
+ })
120
+
70
121
  .post("/models/refresh", async (c) => {
71
122
  const source = await registry.getModelSource();
72
123
  if (!source) {
@@ -1,4 +1,9 @@
1
- import type { ChatImageAttachment, ClientEnvironment } from "@schlessera/brain-ui-sdk/protocol";
1
+ import type {
2
+ BillingMode,
3
+ ChatImageAttachment,
4
+ ClientEnvironment,
5
+ } from "@schlessera/brain-ui-sdk/protocol";
6
+ import type { BackendRegistry } from "../agent/backend.js";
2
7
  import type { WSContext } from "./clients.js";
3
8
  import { withSessionId, withTurnScope } from "./frames.js";
4
9
  import { makeBridge, emitTurnError } from "./bridge.js";
@@ -116,7 +121,14 @@ export async function runSession(
116
121
  turn.timeoutHandle = timeoutHandle;
117
122
 
118
123
  // One recorder per turn identity: a queued follow-up re-mints turnId
119
- // and gets its own run in the activity record.
124
+ // and gets its own run in the activity record. The recorder is handed
125
+ // the RESOLVED profile — `profileId` here is post pin-drop fallback and
126
+ // carries any session_info re-pin forward between queued turns — plus
127
+ // its billing mode, so the run's root span classifies what actually ran,
128
+ // never what was requested.
129
+ const billing = host.activity
130
+ ? await resolveRunBilling(host.registry, backend.id, profileId)
131
+ : undefined;
120
132
  const recorder: TurnRecorder | undefined = host.activity
121
133
  ? createTurnRecorder(
122
134
  {
@@ -124,7 +136,7 @@ export async function runSession(
124
136
  onWrite: () => host.activity!.stream.pump(),
125
137
  log: host.log,
126
138
  },
127
- { turnId: turn.turnId, sessionId: turn.sessionId }
139
+ { turnId: turn.turnId, sessionId: turn.sessionId, ...billing }
128
140
  )
129
141
  : undefined;
130
142
  const bridge = makeBridge(host, turn, text, backend.id, recorder);
@@ -194,6 +206,36 @@ export async function runSession(
194
206
  }
195
207
  }
196
208
 
209
+ /**
210
+ * Profile identity + billing mode for a turn's root span. When the slot has no
211
+ * resolved profile id (a resumed session whose pin was dropped), the backend
212
+ * runs its default profile — the backend's first roster entry — so THAT
213
+ * profile's identity and billing are recorded, not the dead pin's. Hidden
214
+ * profiles are included: a session pinned to a profile the user later hid
215
+ * still runs on it and must classify as it. Never throws: an unreadable
216
+ * roster records no billing attrs, and the rollup falls back to env
217
+ * classification.
218
+ */
219
+ async function resolveRunBilling(
220
+ registry: BackendRegistry,
221
+ backendId: string,
222
+ profileId: string | undefined
223
+ ): Promise<{ profileId: string; billingMode?: BillingMode } | undefined> {
224
+ try {
225
+ const providers = await registry.listAllProviders({ includeHidden: true });
226
+ const resolved = profileId
227
+ ? providers.find((provider) => provider.id === profileId)
228
+ : providers.find((provider) => provider.backendId === backendId);
229
+ if (!resolved) return undefined;
230
+ return {
231
+ profileId: resolved.id,
232
+ ...(resolved.billingMode ? { billingMode: resolved.billingMode } : {}),
233
+ };
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ }
238
+
197
239
  /** Queue sizes are only ever reported to a human, so one decimal of MB is plenty. */
198
240
  function formatMb(bytes: number): string {
199
241
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;