@arcote.tech/arc-chat 0.7.11 → 0.7.12

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.11",
4
+ "version": "0.7.12",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,12 +10,12 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.11",
14
- "@arcote.tech/arc-ai": "^0.7.11",
15
- "@arcote.tech/arc-ai-voice": "^0.7.11",
16
- "@arcote.tech/arc-auth": "^0.7.11",
17
- "@arcote.tech/arc-ds": "^0.7.11",
18
- "@arcote.tech/platform": "^0.7.11",
13
+ "@arcote.tech/arc": "^0.7.12",
14
+ "@arcote.tech/arc-ai": "^0.7.12",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.12",
16
+ "@arcote.tech/arc-auth": "^0.7.12",
17
+ "@arcote.tech/arc-ds": "^0.7.12",
18
+ "@arcote.tech/platform": "^0.7.12",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -55,6 +55,22 @@ export interface ChatReactComponentOptions {
55
55
 
56
56
  // ─── Chat Data ──────────────────────────────────────────────────
57
57
 
58
+ /**
59
+ * Map snapshot token params (decoded payload of the token that protects this
60
+ * chat) to the scopeId we charge for an AI call. Consumers wire this via
61
+ * `.billTo(fn)`. Examples for typical setups:
62
+ *
63
+ * .billTo(p => p.accountId) // per-user billing
64
+ * .billTo(p => p.workspaceId) // per-workspace billing
65
+ *
66
+ * Called inside the ai-generation-listener with `ctx.$auth.params` (which
67
+ * is the decoded payload of the token snapshotted at `messageSent` emit
68
+ * time — i.e. whatever token the chat's `.protectBy(...)` is configured
69
+ * with). The returned string becomes the `_id` of the ledger row that gets
70
+ * debited.
71
+ */
72
+ export type BillToFn = (tokenParams: Record<string, any>) => string;
73
+
58
74
  export interface ArcChatData {
59
75
  name: string;
60
76
  identifyBy: ArcId<any> | null;
@@ -67,6 +83,8 @@ export interface ArcChatData {
67
83
  tools: ArcToolAny[];
68
84
  maxExecutionCount: number;
69
85
  toolChoice: "auto" | "required" | { type: "function"; name: string };
86
+ alias: string | null;
87
+ billTo: BillToFn | null;
70
88
  }
71
89
 
72
90
  const defaultChatData = {
@@ -81,6 +99,8 @@ const defaultChatData = {
81
99
  tools: [],
82
100
  maxExecutionCount: 10,
83
101
  toolChoice: "auto" as const,
102
+ alias: null,
103
+ billTo: null,
84
104
  } as const satisfies ArcChatData;
85
105
 
86
106
  type DefaultChatData = typeof defaultChatData;
@@ -164,6 +184,41 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
164
184
  } as any);
165
185
  }
166
186
 
187
+ /**
188
+ * Billing alias for this chat — written to the `usageRecorded` event
189
+ * payload (see `@arcote.tech/arc-ai`). Defaults to chat `name`. Override
190
+ * when you want consistent reporting across renames or to group multiple
191
+ * chats under one alias for admin SQL reports.
192
+ *
193
+ * chat("identityConsultation").alias("chat-identity")...
194
+ */
195
+ alias<const A extends string>(alias: A) {
196
+ return new ArcChat<Merge<Data, { alias: A }>>({
197
+ ...this.data,
198
+ alias,
199
+ } as any);
200
+ }
201
+
202
+ /**
203
+ * Decide which scopeId to bill for an AI call made from this chat.
204
+ *
205
+ * Called with the decoded params of the token snapshotted at `messageSent`
206
+ * emit time (i.e. the token used in `.protectBy(...)`). The returned string
207
+ * becomes the `_id` of the row in `creditLedger` that gets debited.
208
+ *
209
+ * .billTo(p => p.accountId) // per-user billing
210
+ * .billTo(p => p.workspaceId) // per-workspace billing
211
+ *
212
+ * Required when `.ai(...)` config has billing wired — `build()` throws
213
+ * otherwise. Without billing, this method is a no-op.
214
+ */
215
+ billTo(fn: BillToFn) {
216
+ return new ArcChat<Merge<Data, { billTo: BillToFn }>>({
217
+ ...this.data,
218
+ billTo: fn,
219
+ } as any);
220
+ }
221
+
167
222
  createTool<const N extends string>(name: N) {
168
223
  type IdType = Data["identifyBy"] extends ArcId<any>
169
224
  ? $type<Data["identifyBy"]>
@@ -195,6 +250,8 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
195
250
  tools,
196
251
  maxExecutionCount,
197
252
  toolChoice,
253
+ alias: aliasOverride,
254
+ billTo,
198
255
  } = this.data;
199
256
 
200
257
  if (!name) throw new Error("ArcChat: name is required");
@@ -202,6 +259,13 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
202
259
  if (!accountId) throw new Error("ArcChat: accountId is required");
203
260
  if (!userToken) throw new Error("ArcChat: userToken is required");
204
261
  if (!aiConfig) throw new Error("ArcChat: ai is required");
262
+ // Billing wired but no `.billTo(...)` would silently skip recordUsage —
263
+ // forbid that. Consumer must explicitly decide which scopeId to charge.
264
+ if (aiConfig.recordUsage && !billTo) {
265
+ throw new Error(
266
+ `ArcChat "${name}": ai() factory has billing wired but chat is missing .billTo(...) — declare how to map snapshot token params to a billing scope, e.g. .billTo(p => p.accountId).`,
267
+ );
268
+ }
205
269
 
206
270
  const messageId = createMessageId({ name });
207
271
 
@@ -244,13 +308,14 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
244
308
  const serverTools = tools.filter((t) => t.isServerTool);
245
309
  const interactiveTools = tools.filter((t) => t.isInteractiveTool);
246
310
 
247
- // Add ledger element to mutation deps if billing configured
248
- const billingElements: ArcContextElement<any>[] = [];
249
- if (aiConfig.billing) {
250
- for (const el of aiConfig.billing.ledger.elements) {
311
+ // Add usage-registry aggregate to listener mutate deps so `recordUsage`
312
+ // (which calls `ctx.mutate(registry).recordUsage(...)`) compiles and runs.
313
+ // Ledger view is a read-only projection — consumer queries it from React,
314
+ // listener never writes to it directly.
315
+ if (aiConfig.usageRegistry) {
316
+ for (const el of aiConfig.usageRegistry.elements) {
251
317
  if (!allMutationElements.includes(el)) allMutationElements.push(el);
252
318
  if (!allQueryElements.includes(el)) allQueryElements.push(el);
253
- billingElements.push(el);
254
319
  }
255
320
  }
256
321
 
@@ -265,6 +330,9 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
265
330
  allMutationElements,
266
331
  maxExecutionCount,
267
332
  toolChoice: toolChoice !== "auto" ? toolChoice : undefined,
333
+ alias: aliasOverride ?? name,
334
+ recordUsage: aiConfig.recordUsage,
335
+ billTo: billTo ?? undefined,
268
336
  };
269
337
 
270
338
  const aiListener = createAiGenerationListener(listenerConfig);
@@ -28,6 +28,37 @@ export interface AiGenerationListenerConfig {
28
28
  allMutationElements: ArcContextElement<any>[];
29
29
  maxExecutionCount: number;
30
30
  toolChoice?: "auto" | "required" | { type: "function"; name: string };
31
+ /**
32
+ * Billing alias for this chat — written to the `usageRecorded` event
33
+ * payload so admin reports can attribute cost back to which chat (e.g.
34
+ * `"chat-identity"`, `"chat-create-content"`). Defaults to `name` if the
35
+ * builder didn't override via `.alias(...)`.
36
+ */
37
+ alias?: string;
38
+ /**
39
+ * Optional billing hook from `ai()` factory. Called after every
40
+ * `completeAssistantTurn` (each turn, including those that close with
41
+ * tool calls) so the credit ledger view sees consistent usage events.
42
+ * No-op when undefined (ai() built without `billing` config).
43
+ */
44
+ recordUsage?: (
45
+ ctx: any,
46
+ params: {
47
+ scopeId: string;
48
+ alias: string;
49
+ model: string;
50
+ usage: import("@arcote.tech/arc-ai").TokenUsage;
51
+ metadata?: Record<string, unknown>;
52
+ },
53
+ ) => Promise<void>;
54
+ /**
55
+ * Consumer-supplied function from chat-builder's `.billTo(...)` — maps
56
+ * decoded params of the chat's protection token (snapshotted at
57
+ * `messageSent` emit time) to the ledger scopeId we charge. Required when
58
+ * `recordUsage` is set (chat-builder enforces this at build time), so the
59
+ * listener can treat the pair as always-present in the call site.
60
+ */
61
+ billTo?: (tokenParams: Record<string, any>) => string;
31
62
  }
32
63
 
33
64
  // ─── History reconstruction ─────────────────────────────────────
@@ -195,6 +226,13 @@ interface RunLoopConfig {
195
226
  * lecieć. Następne iteracje (multi-turn po server tool exec) tworzą fresh
196
227
  * rows. */
197
228
  preCreatedAssistantMessageId?: string;
229
+ /** Billing alias — written to `usageRecorded` event payload. Optional;
230
+ * no-op when paired `recordUsage` is undefined. */
231
+ alias?: string;
232
+ /** Billing hook — see `AiGenerationListenerConfig.recordUsage`. */
233
+ recordUsage?: AiGenerationListenerConfig["recordUsage"];
234
+ /** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
235
+ billTo?: AiGenerationListenerConfig["billTo"];
198
236
  }
199
237
 
200
238
  async function runGenerationLoop(config: RunLoopConfig) {
@@ -339,15 +377,46 @@ async function runGenerationLoop(config: RunLoopConfig) {
339
377
 
340
378
  // Close the turn row — same row that was opened above. Final blocks
341
379
  // are the SINGLE persistent write of message content in this turn.
342
- // Intermediate turns (gdy mamy tool calls) carry blocks + responseId;
343
- // ostatni turn nosi też usage.
380
+ // Usage zapisywane ZAWSZE (intermediate tool turns też zużywają tokeny
381
+ // i muszą być rozliczone w `recordUsage` poniżej; klient dostaje pełen
382
+ // history of cost per turn from message rows).
344
383
  await ctx.mutate(messageElement).completeAssistantTurn({
345
384
  messageId: currentTurnId!,
346
385
  blocks: JSON.stringify(result.blocks),
347
386
  previousResponseId: result.responseId,
348
- usage: hasToolCalls ? undefined : JSON.stringify(result.usage),
387
+ usage: JSON.stringify(result.usage),
349
388
  });
350
389
 
390
+ // Billing hook — emit usageRecorded event for the credit ledger view.
391
+ // Called after `completeAssistantTurn` so the message row exists in DB
392
+ // before its cost is attributed.
393
+ //
394
+ // chat-builder enforces `.billTo()` is present whenever `recordUsage`
395
+ // is wired, so the only legitimate "skip" path is "billing not
396
+ // configured at all" — both undefined. We still guard defensively.
397
+ if (config.recordUsage && config.alias && config.billTo) {
398
+ const billingScopeId = config.billTo(
399
+ ((ctx as any).$auth?.params as Record<string, any>) ?? {},
400
+ );
401
+ try {
402
+ await config.recordUsage(ctx, {
403
+ scopeId: billingScopeId,
404
+ alias: config.alias,
405
+ model,
406
+ usage: result.usage,
407
+ metadata: {
408
+ messageId: currentTurnId!,
409
+ sessionId,
410
+ turnIndex: executionCount,
411
+ chatScopeId: scopeId,
412
+ },
413
+ });
414
+ } catch (err) {
415
+ // Best-effort: billing failure shouldn't break generation.
416
+ console.error("[arc-chat] recordUsage failed:", err);
417
+ }
418
+ }
419
+
351
420
  // Tear down the in-memory stream: broadcast `done` do subscriberów,
352
421
  // close controllery, drop registry entry po grace window. Klient z
353
422
  // `done` flippuje isStreaming=false i renderuje final blocks z DB.
@@ -516,6 +585,9 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
516
585
  maxExecutionCount,
517
586
  toolChoice: config.toolChoice,
518
587
  instruction,
588
+ alias: config.alias,
589
+ recordUsage: config.recordUsage,
590
+ billTo: config.billTo,
519
591
  preCreatedAssistantMessageId: (
520
592
  event.payload as { assistantMessageId?: string }
521
593
  ).assistantMessageId,
@@ -596,6 +668,9 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
596
668
  maxExecutionCount,
597
669
  toolChoice: config.toolChoice,
598
670
  instruction,
671
+ alias: config.alias,
672
+ recordUsage: config.recordUsage,
673
+ billTo: config.billTo,
599
674
  preCreatedAssistantMessageId: (
600
675
  event.payload as { assistantMessageId?: string }
601
676
  ).assistantMessageId,
@@ -688,6 +763,9 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
688
763
  maxExecutionCount,
689
764
  toolChoice: config.toolChoice,
690
765
  instruction,
766
+ alias: config.alias,
767
+ recordUsage: config.recordUsage,
768
+ billTo: config.billTo,
691
769
  preCreatedAssistantMessageId: assistantMsgId,
692
770
  });
693
771
  });