niahere 0.5.1 → 0.5.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,9 +44,9 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.3.190",
48
- "@anthropic-ai/sdk": "^0.105.0",
49
- "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
48
+ "@anthropic-ai/sdk": "0.115.0",
49
+ "@modelcontextprotocol/sdk": "1.30.0",
50
50
  "@slack/bolt": "^4.6.0",
51
51
  "cron-parser": "^5.5.0",
52
52
  "grammy": "^1.41.1",
@@ -1,6 +1,36 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
3
  import { isRetryable, scopeOf, parseFailure } from "../failure";
4
+ import type { FailoverScope } from "../types";
5
+
6
+ /**
7
+ * Terminal reasons that mean the turn died. The SDK used to report several of
8
+ * these as `completed`, so a dead turn landed in the audit as a successful run.
9
+ * The value is how far the chain should skip; undefined means stop.
10
+ */
11
+ const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
12
+ api_error: "provider",
13
+ budget_exhausted: undefined,
14
+ malformed_tool_use_exhausted: undefined,
15
+ structured_output_retry_exhausted: undefined,
16
+ tool_deferred_unavailable: undefined,
17
+ turn_setup_failed: undefined,
18
+ };
19
+
20
+ /**
21
+ * Stamp the chain's provider over the SDK's own, which names the deployment
22
+ * target (firstParty/bedrock/vertex) — a different axis from which backend ran
23
+ * the turn, and the one `providers_used` has to answer.
24
+ */
25
+ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
26
+ if (!modelUsage || typeof modelUsage !== "object") return undefined;
27
+ return Object.fromEntries(
28
+ Object.entries(modelUsage as Record<string, unknown>).map(([model, usage]) => [
29
+ model,
30
+ { ...(usage as object), provider: "claude" },
31
+ ]),
32
+ );
33
+ }
4
34
 
5
35
  /**
6
36
  * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
@@ -41,6 +71,17 @@ export class SdkNormalizer implements Normalizer {
41
71
  return [];
42
72
  }
43
73
 
74
+ if (msg.type === "system" && msg.subtype === "compact_boundary") {
75
+ // Long sessions compact silently; surface it so the transcript records
76
+ // that history was summarized and how much was dropped.
77
+ const meta = msg.compact_metadata ?? {};
78
+ const trigger = meta.trigger === "manual" ? "manual" : "auto";
79
+ const pre = meta.pre_tokens ?? 0;
80
+ const post = meta.post_tokens;
81
+ const shrink = post !== undefined ? `${pre} → ${post} tokens` : `${pre} tokens`;
82
+ return [{ type: "thinking", delta: `context compacted (${trigger}, ${shrink})` }];
83
+ }
84
+
44
85
  if (msg.type === "system") {
45
86
  // Subagent/task lifecycle (subtype init handled above).
46
87
  if (msg.subtype === "task_started" && msg.description) {
@@ -98,6 +139,16 @@ export class SdkNormalizer implements Normalizer {
98
139
  }
99
140
 
100
141
  private consumeResult(msg: any): AgentEvent {
142
+ const deadTurn = msg.terminal_reason as string | undefined;
143
+ if (!msg.is_error && deadTurn && deadTurn in DEAD_TURNS) {
144
+ return {
145
+ type: "error",
146
+ message: (msg.errors?.join(", ") as string) || `turn ended: ${deadTurn}`,
147
+ retryable: false,
148
+ failover: DEAD_TURNS[deadTurn],
149
+ terminalReason: deadTurn,
150
+ };
151
+ }
101
152
  if (!msg.is_error) {
102
153
  return {
103
154
  type: "result",
@@ -115,7 +166,7 @@ export class SdkNormalizer implements Normalizer {
115
166
  session_id: msg.session_id,
116
167
  subtype: msg.subtype,
117
168
  usage: msg.usage,
118
- model_usage: msg.modelUsage,
169
+ model_usage: attribute(msg.modelUsage),
119
170
  },
120
171
  };
121
172
  }
@@ -126,7 +177,11 @@ export class SdkNormalizer implements Normalizer {
126
177
  type: "error",
127
178
  message: raw,
128
179
  retryable: isRetryable(raw),
129
- failover: scopeOf(parseFailure(raw), "provider"),
180
+ // api_error_status is the reliable signal; prose is the fallback.
181
+ failover: scopeOf(
182
+ { ...parseFailure(raw), status: typeof msg.api_error_status === "number" ? msg.api_error_status : undefined },
183
+ "provider",
184
+ ),
130
185
  terminalReason: msg.terminal_reason,
131
186
  };
132
187
  }
@@ -14,6 +14,10 @@ import { scopeOf, parseFailure } from "../failure";
14
14
  * skill budget) and are dropped.
15
15
  */
16
16
  export class CodexNormalizer implements Normalizer {
17
+ /** The model this run was launched on, for usage attribution — codex's own
18
+ * events don't name it. */
19
+ constructor(private readonly model?: string) {}
20
+
17
21
  private threadId = "";
18
22
  private agentText = "";
19
23
  private failed = false;
@@ -35,20 +39,37 @@ export class CodexNormalizer implements Normalizer {
35
39
  return this.fail(typeof e.message === "string" ? e.message : "");
36
40
  case "turn.failed":
37
41
  return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
38
- case "turn.completed":
42
+ case "turn.completed": {
43
+ // Codex counts like OpenAI: `input_tokens` is the whole prompt with
44
+ // `cached_input_tokens` a slice of it. Claude reports the two as
45
+ // siblings and one accumulator sums both, so the slice comes out here.
46
+ const cacheRead = e.usage?.cached_input_tokens ?? 0;
47
+ const input = Math.max(0, (e.usage?.input_tokens ?? 0) - cacheRead);
48
+ const output = e.usage?.output_tokens ?? 0;
39
49
  return [
40
50
  {
41
51
  type: "result",
42
52
  text: this.agentText,
43
- usage: {
44
- tokens: {
45
- input: e.usage?.input_tokens ?? 0,
46
- output: e.usage?.output_tokens ?? 0,
53
+ usage: { tokens: { input, output } },
54
+ backendSessionId: this.threadId,
55
+ // Same shape the Claude path emits, so one accumulator serves both
56
+ // and a failed-over turn is attributable to the provider that ran it.
57
+ // No cost: codex reports none, and an invented zero would read as a
58
+ // free turn.
59
+ metadata: {
60
+ model_usage: {
61
+ [this.model || "default"]: {
62
+ provider: "codex",
63
+ inputTokens: input,
64
+ outputTokens: output,
65
+ cacheReadInputTokens: cacheRead,
66
+ cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
67
+ },
47
68
  },
48
69
  },
49
- backendSessionId: this.threadId,
50
70
  },
51
71
  ];
72
+ }
52
73
  default:
53
74
  return [];
54
75
  }
@@ -201,7 +201,7 @@ class CodexSession implements AgentSession {
201
201
  // Started now, not after exit: an undrained pipe blocks the child.
202
202
  const stderr = drainStderr(proc.stderr);
203
203
 
204
- const normalizer = new CodexNormalizer();
204
+ const normalizer = new CodexNormalizer(this.ctx.model);
205
205
  const stdout = proc.stdout.getReader();
206
206
  const lines = readLines(stdout)[Symbol.asyncIterator]();
207
207
  let sawTerminal = false;
@@ -82,8 +82,10 @@ function scopeOfStatus(status: number, message: string): FailoverScope | undefin
82
82
  */
83
83
  export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
84
84
  const t = failure.message.trim();
85
- if (!t || t.toLowerCase() === "unknown error") return blank;
85
+ // A status is authoritative even when the message is empty — the prose that
86
+ // accompanies a 429 or 529 is routinely unhelpful.
86
87
  if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
88
+ if (!t || t.toLowerCase() === "unknown error") return blank;
87
89
  if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
88
90
  if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
89
91
  return undefined;
@@ -323,7 +323,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
323
323
  };
324
324
  try {
325
325
  messageId = await Message.save(saveParams);
326
- } catch {
326
+ } catch (e) {
327
+ // Saving the reply matters more than saving its usage, but a
328
+ // silent drop cost eight days of the ledger before anyone
329
+ // looked.
330
+ log.error({ room, error: errMsg(e) }, "dropping turn metadata; message saved without it");
327
331
  messageId = await Message.save({ ...saveParams, metadata: undefined });
328
332
  }
329
333
  await Session.touch(sessionId);
package/src/cli/index.ts CHANGED
@@ -203,6 +203,12 @@ switch (command) {
203
203
  break;
204
204
  }
205
205
 
206
+ case "usage": {
207
+ const { usageCommand } = await import("./usage");
208
+ await usageCommand(process.argv.slice(3));
209
+ break;
210
+ }
211
+
206
212
  case "seed": {
207
213
  await import("../db/seed");
208
214
  break;
@@ -337,6 +343,7 @@ Daemon:
337
343
  model [name] Show or set global Claude model
338
344
  health Check daemon, db, channels, config
339
345
  logs [-f] [--channel ch] Daemon logs (filter by channel)
346
+ usage [--days N --by dim --room R --json] Tokens and cost per day/model/provider/room
340
347
 
341
348
  Chat:
342
349
  chat [-c] [-r] [--employee|--agent|--job name] Interactive chat
@@ -0,0 +1,189 @@
1
+ import { withDb } from "../db/with-db";
2
+ import { queryUsage, type UsageRow } from "../db/models/usage";
3
+ import { getConfig } from "../utils/config";
4
+ import { BOLD, DIM, RESET, fail } from "../utils/cli";
5
+ import { errMsg } from "../utils/errors";
6
+
7
+ export type Dimension = "day" | "model" | "provider" | "room";
8
+
9
+ const DIMENSIONS: Dimension[] = ["day", "model", "provider", "room"];
10
+
11
+ export interface UsageOptions {
12
+ days: number;
13
+ by: Dimension;
14
+ room?: string;
15
+ json: boolean;
16
+ }
17
+
18
+ export interface UsageBucket {
19
+ key: string;
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheWriteTokens: number;
24
+ /** null when nothing in the bucket carried a cost. */
25
+ costUsd: number | null;
26
+ turns: number;
27
+ unpricedTurns: number;
28
+ }
29
+
30
+ export function parseUsageArgs(argv: string[]): UsageOptions {
31
+ const opts: UsageOptions = { days: 7, by: "day", json: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const arg = argv[i]!;
34
+ const value = argv[i + 1];
35
+ if (arg === "--json") {
36
+ opts.json = true;
37
+ } else if (arg === "--days" && value) {
38
+ const parsed = Number.parseInt(value, 10);
39
+ if (Number.isInteger(parsed) && parsed > 0) opts.days = parsed;
40
+ i += 1;
41
+ } else if (arg === "--by" && value) {
42
+ if (!DIMENSIONS.includes(value as Dimension)) throw new Error(`--by must be one of: ${DIMENSIONS.join(", ")}`);
43
+ opts.by = value as Dimension;
44
+ i += 1;
45
+ } else if (arg === "--room" && value) {
46
+ opts.room = value;
47
+ i += 1;
48
+ }
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ export function rollup(rows: UsageRow[], by: Dimension): UsageBucket[] {
54
+ const byKey = new Map<string, UsageBucket>();
55
+ for (const row of rows) {
56
+ const key = row[by];
57
+ const seen = byKey.get(key);
58
+ if (!seen) {
59
+ byKey.set(key, {
60
+ key,
61
+ inputTokens: row.inputTokens,
62
+ outputTokens: row.outputTokens,
63
+ cacheReadTokens: row.cacheReadTokens,
64
+ cacheWriteTokens: row.cacheWriteTokens,
65
+ costUsd: row.costUsd,
66
+ turns: row.turns,
67
+ unpricedTurns: row.unpricedTurns,
68
+ });
69
+ continue;
70
+ }
71
+ seen.inputTokens += row.inputTokens;
72
+ seen.outputTokens += row.outputTokens;
73
+ seen.cacheReadTokens += row.cacheReadTokens;
74
+ seen.cacheWriteTokens += row.cacheWriteTokens;
75
+ seen.turns += row.turns;
76
+ seen.unpricedTurns += row.unpricedTurns;
77
+ // A known cost survives an unknown one beside it; only an all-unknown
78
+ // bucket reports nothing.
79
+ seen.costUsd = seen.costUsd === null && row.costUsd === null ? null : (seen.costUsd ?? 0) + (row.costUsd ?? 0);
80
+ }
81
+
82
+ const buckets = [...byKey.values()];
83
+ return by === "day"
84
+ ? buckets.sort((a, b) => a.key.localeCompare(b.key))
85
+ : buckets.sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0) || b.turns - a.turns);
86
+ }
87
+
88
+ export function compactTokens(n: number): string {
89
+ if (n >= 1_000_000) return `${round(n / 1_000_000)}M`;
90
+ if (n >= 1_000) return `${round(n / 1_000)}K`;
91
+ return String(n);
92
+ }
93
+
94
+ function round(n: number): string {
95
+ return n.toFixed(1).replace(/\.0$/, "");
96
+ }
97
+
98
+ export function formatCost(cost: number | null): string {
99
+ if (cost === null) return "—";
100
+ // Sub-cent turns are common; rounding them to $0.00 hides real spend.
101
+ return cost > 0 && cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
102
+ }
103
+
104
+ const COLUMNS: [string, (b: UsageBucket) => string][] = [
105
+ ["Turns", (b) => String(b.turns)],
106
+ ["Input", (b) => compactTokens(b.inputTokens)],
107
+ ["Output", (b) => compactTokens(b.outputTokens)],
108
+ ["Cache rd", (b) => compactTokens(b.cacheReadTokens)],
109
+ ["Cache wr", (b) => compactTokens(b.cacheWriteTokens)],
110
+ ["Cost", (b) => formatCost(b.costUsd)],
111
+ ];
112
+
113
+ export function renderTable(buckets: UsageBucket[], label: string): string[] {
114
+ const header = [label, ...COLUMNS.map(([name]) => name)];
115
+ const body = buckets.map((b) => [b.key, ...COLUMNS.map(([, read]) => read(b))]);
116
+ const total = buckets.reduce<UsageBucket>((sum, b) => {
117
+ sum.inputTokens += b.inputTokens;
118
+ sum.outputTokens += b.outputTokens;
119
+ sum.cacheReadTokens += b.cacheReadTokens;
120
+ sum.cacheWriteTokens += b.cacheWriteTokens;
121
+ sum.turns += b.turns;
122
+ sum.unpricedTurns += b.unpricedTurns;
123
+ sum.costUsd = sum.costUsd === null && b.costUsd === null ? null : (sum.costUsd ?? 0) + (b.costUsd ?? 0);
124
+ return sum;
125
+ }, emptyBucket("Total"));
126
+
127
+ const rows = [header, ...body, [total.key, ...COLUMNS.map(([, read]) => read(total))]];
128
+ const widths = header.map((_, col) => Math.max(...rows.map((r) => r[col]!.length)));
129
+ const line = (cells: string[]) =>
130
+ cells.map((cell, col) => (col === 0 ? cell.padEnd(widths[col]!) : cell.padStart(widths[col]!))).join(" ");
131
+
132
+ const out = [`${BOLD}${line(header)}${RESET}`, ...body.map(line), `${BOLD}${line(rows.at(-1)!)}${RESET}`];
133
+ if (total.unpricedTurns > 0) {
134
+ out.push(`${DIM}${total.unpricedTurns} of ${total.turns} turns report no cost (codex bills no per-token price)${RESET}`);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ function emptyBucket(key: string): UsageBucket {
140
+ return {
141
+ key,
142
+ inputTokens: 0,
143
+ outputTokens: 0,
144
+ cacheReadTokens: 0,
145
+ cacheWriteTokens: 0,
146
+ costUsd: null,
147
+ turns: 0,
148
+ unpricedTurns: 0,
149
+ };
150
+ }
151
+
152
+ export async function usageCommand(argv: string[]): Promise<void> {
153
+ let opts: UsageOptions;
154
+ try {
155
+ opts = parseUsageArgs(argv);
156
+ } catch (e) {
157
+ fail(errMsg(e));
158
+ }
159
+ const since = new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000);
160
+ const timezone = getConfig().timezone || "UTC";
161
+
162
+ const rows = await withDb(() =>
163
+ queryUsage({ since, ...(opts.room !== undefined ? { room: opts.room } : {}), timezone }),
164
+ );
165
+
166
+ if (opts.json) {
167
+ console.log(JSON.stringify({ since: since.toISOString(), timezone, rows }, null, 2));
168
+ return;
169
+ }
170
+
171
+ if (rows.length === 0) {
172
+ console.log(`No recorded usage in the last ${opts.days} days${opts.room ? ` for ${opts.room}` : ""}.`);
173
+ return;
174
+ }
175
+
176
+ const scope = opts.room ? ` · ${opts.room}` : "";
177
+ console.log(`\nUsage — last ${opts.days} days${scope} (${timezone})\n`);
178
+ for (const line of renderTable(rollup(rows, opts.by), title(opts.by))) console.log(line);
179
+
180
+ if (opts.by === "day") {
181
+ console.log("\nBy model");
182
+ for (const line of renderTable(rollup(rows, "model"), "Model")) console.log(` ${line}`);
183
+ }
184
+ console.log();
185
+ }
186
+
187
+ function title(by: Dimension): string {
188
+ return by === "day" ? "Day" : by[0]!.toUpperCase() + by.slice(1);
189
+ }
@@ -122,30 +122,74 @@ export async function getRecentSummaries(
122
122
  }));
123
123
  }
124
124
 
125
+ export interface UsageTotals {
126
+ inputTokens: number;
127
+ outputTokens: number;
128
+ cacheReadTokens: number;
129
+ cacheCreationTokens: number;
130
+ models: string[];
131
+ providers: string[];
132
+ }
133
+
134
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
135
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
136
+
137
+ /**
138
+ * Fold a result's per-model usage into totals. `provider` matters because a
139
+ * chain that fails over makes the model name alone ambiguous about who served
140
+ * the turn.
141
+ */
142
+ export function summarizeModelUsage(modelUsage: unknown): UsageTotals {
143
+ const totals: UsageTotals = {
144
+ inputTokens: 0,
145
+ outputTokens: 0,
146
+ cacheReadTokens: 0,
147
+ cacheCreationTokens: 0,
148
+ models: [],
149
+ providers: [],
150
+ };
151
+ if (!modelUsage || typeof modelUsage !== "object") return totals;
152
+
153
+ const models = new Set<string>();
154
+ const providers = new Set<string>();
155
+ for (const [key, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
156
+ const usage = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
157
+ models.add(str(usage.canonicalModel) ?? key);
158
+ const provider = str(usage.provider);
159
+ if (provider) providers.add(provider);
160
+ totals.inputTokens += num(usage.inputTokens);
161
+ totals.outputTokens += num(usage.outputTokens);
162
+ totals.cacheReadTokens += num(usage.cacheReadInputTokens);
163
+ totals.cacheCreationTokens += num(usage.cacheCreationInputTokens);
164
+ }
165
+ totals.models = [...models];
166
+ totals.providers = [...providers];
167
+ return totals;
168
+ }
169
+
125
170
  export async function accumulateMetadata(id: string, resultMeta: Record<string, unknown>): Promise<void> {
126
171
  const sql = getSql();
127
172
 
128
- const modelUsage = resultMeta.model_usage as Record<string, Record<string, number>> | undefined;
129
- let inputTokens = 0;
130
- let outputTokens = 0;
131
- let cacheReadTokens = 0;
132
- let cacheCreationTokens = 0;
133
- const newModels: string[] = [];
134
- if (modelUsage) {
135
- for (const [model, usage] of Object.entries(modelUsage)) {
136
- newModels.push(model);
137
- inputTokens += usage.inputTokens || 0;
138
- outputTokens += usage.outputTokens || 0;
139
- cacheReadTokens += usage.cacheReadInputTokens || 0;
140
- cacheCreationTokens += usage.cacheCreationInputTokens || 0;
141
- }
142
- }
173
+ const {
174
+ inputTokens,
175
+ outputTokens,
176
+ cacheReadTokens,
177
+ cacheCreationTokens,
178
+ models: newModels,
179
+ providers: newProviders,
180
+ } = summarizeModelUsage(resultMeta.model_usage);
143
181
 
144
182
  // Bind deltas as jsonb via sql.json — pre-stringifying would store a
145
183
  // double-encoded string scalar, making every `->>` extraction return NULL
146
184
  // and silently zeroing all accumulated totals.
185
+ // Codex reports tokens but no cost. Folding that in as $0 would make a
186
+ // session that failed over look cheaper than one that never left Claude, so
187
+ // the unknown is counted rather than absorbed.
188
+ const priced = typeof resultMeta.cost_usd === "number";
189
+
147
190
  const delta = sql.json({
148
191
  total_cost_usd: (resultMeta.cost_usd as number) || 0,
192
+ unpriced_turns: priced ? 0 : 1,
149
193
  total_turns: (resultMeta.turns as number) || 0,
150
194
  total_duration_ms: (resultMeta.duration_ms as number) || 0,
151
195
  total_duration_api_ms: (resultMeta.duration_api_ms as number) || 0,
@@ -155,14 +199,17 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
155
199
  total_cache_creation_tokens: cacheCreationTokens,
156
200
  message_count: 1,
157
201
  models_used: newModels,
202
+ providers_used: newProviders,
158
203
  channel: (resultMeta.channel as string) || null,
159
204
  });
160
205
  const modelsDelta = sql.json(newModels);
206
+ const providersDelta = sql.json(newProviders);
161
207
 
162
208
  // Atomic accumulate — no read-then-write race
163
209
  await sql`
164
210
  UPDATE sessions SET metadata = jsonb_build_object(
165
211
  'total_cost_usd', COALESCE((metadata->>'total_cost_usd')::real, 0) + (${delta}->>'total_cost_usd')::real,
212
+ 'unpriced_turns', COALESCE((metadata->>'unpriced_turns')::int, 0) + (${delta}->>'unpriced_turns')::int,
166
213
  'total_turns', COALESCE((metadata->>'total_turns')::int, 0) + (${delta}->>'total_turns')::int,
167
214
  'total_duration_ms', COALESCE((metadata->>'total_duration_ms')::real, 0) + (${delta}->>'total_duration_ms')::real,
168
215
  'total_duration_api_ms', COALESCE((metadata->>'total_duration_api_ms')::real, 0) + (${delta}->>'total_duration_api_ms')::real,
@@ -173,6 +220,8 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
173
220
  'message_count', COALESCE((metadata->>'message_count')::int, 0) + 1,
174
221
  'models_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
175
222
  FROM jsonb_array_elements(COALESCE(metadata->'models_used', '[]'::jsonb) || ${modelsDelta}) AS e),
223
+ 'providers_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
224
+ FROM jsonb_array_elements(COALESCE(metadata->'providers_used', '[]'::jsonb) || ${providersDelta}) AS e),
176
225
  'channel', COALESCE(metadata->>'channel', ${(resultMeta.channel as string) || null})
177
226
  )
178
227
  WHERE id = ${id}
Binary file