@vellumai/assistant 0.10.0-dev.202606201736.e485b5f → 0.10.0-dev.202606201937.7a5da97

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/openapi.yaml CHANGED
@@ -27120,6 +27120,10 @@ paths:
27120
27120
  type: number
27121
27121
  eventCount:
27122
27122
  type: number
27123
+ turnCount:
27124
+ anyOf:
27125
+ - type: number
27126
+ - type: "null"
27123
27127
  required:
27124
27128
  - group
27125
27129
  - groupId
@@ -27129,6 +27133,7 @@ paths:
27129
27133
  - totalCacheReadTokens
27130
27134
  - totalEstimatedCostUsd
27131
27135
  - eventCount
27136
+ - turnCount
27132
27137
  additionalProperties: false
27133
27138
  description: Grouped usage entries
27134
27139
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.0-dev.202606201736.e485b5f",
3
+ "version": "0.10.0-dev.202606201937.7a5da97",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1306,6 +1306,41 @@ describe("getUsageGroupBreakdown", () => {
1306
1306
  expect(groups[0].totalOutputTokens).toBe(125);
1307
1307
  expect(groups[0].totalEstimatedCostUsd).toBeCloseTo(0.05);
1308
1308
  expect(groups[0].eventCount).toBe(2);
1309
+ // No user messages were inserted, so the conversation has zero turns.
1310
+ expect(groups[0].turnCount).toBe(0);
1311
+ });
1312
+
1313
+ test("counts conversation turns from eligible user messages when grouping by conversation", () => {
1314
+ const db = getDb();
1315
+ const conversationId = "conv-turns-1";
1316
+ const now = Date.now();
1317
+ db.run(
1318
+ `INSERT INTO conversations (id, title, created_at, updated_at) VALUES ('${conversationId}', 'Turn counting', ${now}, ${now})`,
1319
+ );
1320
+ // Two user turns; the second turn has two LLM calls, but the count comes
1321
+ // from the user messages (2), not the number of LLM calls (3).
1322
+ db.run(
1323
+ `INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u1', '${conversationId}', 'user', 'first', 500)`,
1324
+ );
1325
+ db.run(
1326
+ `INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u2', '${conversationId}', 'user', 'second', 2000)`,
1327
+ );
1328
+ // Tool-result user messages are not real turns and must be excluded.
1329
+ db.run(
1330
+ `INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('tr1', '${conversationId}', 'user', '[{"type":"tool_result","tool_use_id":"x","content":""}]', 2100)`,
1331
+ );
1332
+ insertEventAt(1000, { conversationId, inputTokens: 100 });
1333
+ insertEventAt(2500, { conversationId, inputTokens: 100 });
1334
+ insertEventAt(2600, { conversationId, inputTokens: 100 });
1335
+
1336
+ const groups = getUsageGroupBreakdown(
1337
+ { from: 0, to: 5000 },
1338
+ "conversation",
1339
+ );
1340
+ expect(groups).toHaveLength(1);
1341
+ expect(groups[0].groupId).toBe(conversationId);
1342
+ expect(groups[0].eventCount).toBe(3);
1343
+ expect(groups[0].turnCount).toBe(2);
1309
1344
  });
1310
1345
 
1311
1346
  test("returns groupId null for the Other bucket when grouping by conversation and events have no conversation id", () => {
@@ -1329,6 +1364,8 @@ describe("getUsageGroupBreakdown", () => {
1329
1364
  expect(groups[0].groupId).toBeNull();
1330
1365
  expect(groups[0].totalInputTokens).toBe(300);
1331
1366
  expect(groups[0].eventCount).toBe(2);
1367
+ // The Other bucket has no parent conversation, so turns are undefined.
1368
+ expect(groups[0].turnCount).toBeNull();
1332
1369
  });
1333
1370
 
1334
1371
  test("returns groupId null for every row when grouping by a non-conversation dimension", () => {
@@ -1348,6 +1385,8 @@ describe("getUsageGroupBreakdown", () => {
1348
1385
  expect(groups.length).toBeGreaterThan(0);
1349
1386
  for (const row of groups) {
1350
1387
  expect(row.groupId).toBeNull();
1388
+ // Turns are only computed for the conversation grouping.
1389
+ expect(row.turnCount).toBeNull();
1351
1390
  }
1352
1391
  });
1353
1392
  });
@@ -363,6 +363,14 @@ export interface UsageGroupBreakdown {
363
363
  totalCacheReadTokens: number;
364
364
  totalEstimatedCostUsd: number;
365
365
  eventCount: number;
366
+ /**
367
+ * Number of turns in the conversation — the count of eligible user messages
368
+ * (tool-result turns excluded). Only populated when `groupBy ===
369
+ * "conversation"` (and `null` for that mode's "Other" bucket, which has no
370
+ * parent conversation). `null` for every other grouping, where a turn count
371
+ * has no well-defined meaning.
372
+ */
373
+ turnCount: number | null;
366
374
  }
367
375
 
368
376
  // -- raw row shapes returned by SQLite aggregation queries --
@@ -388,6 +396,8 @@ interface GroupRow {
388
396
  total_cache_read_tokens: number;
389
397
  total_estimated_cost_usd: number | null;
390
398
  event_count: number;
399
+ /** Only selected by the conversation grouping query; absent otherwise. */
400
+ turn_count?: number | null;
391
401
  }
392
402
 
393
403
  type UsageQueryParam = ScheduleAttributionSqlParam;
@@ -645,6 +655,9 @@ function mapGroupRow(
645
655
  totalCacheReadTokens: row.total_cache_read_tokens,
646
656
  totalEstimatedCostUsd: row.total_estimated_cost_usd ?? 0,
647
657
  eventCount: row.event_count,
658
+ // Turns are only meaningful per conversation; other dimensions select no
659
+ // turn_count column, so this collapses to null.
660
+ turnCount: groupBy === "conversation" ? (row.turn_count ?? null) : null,
648
661
  };
649
662
  }
650
663
 
@@ -678,7 +691,24 @@ export function getUsageGroupBreakdown(
678
691
  COALESCE(SUM(e.cache_creation_input_tokens), 0) AS total_cache_creation_tokens,
679
692
  COALESCE(SUM(e.cache_read_input_tokens), 0) AS total_cache_read_tokens,
680
693
  COALESCE(SUM(e.estimated_cost_usd), 0) AS total_estimated_cost_usd,
681
- COALESCE(SUM(COALESCE(e.llm_call_count, 1)), 0) AS event_count
694
+ COALESCE(SUM(COALESCE(e.llm_call_count, 1)), 0) AS event_count,
695
+ -- Number of turns in the conversation: the count of eligible user
696
+ -- messages (tool-result turns excluded, mirroring the turnIndex
697
+ -- definition). Evaluated once per conversation group via the
698
+ -- idx_messages_conversation_id index rather than once per usage event,
699
+ -- so it stays cheap as call volume grows. NULL for the "Other" (no
700
+ -- conversation) bucket. Note: derived from surviving messages, so a
701
+ -- turn removed via Undo (deleteLastExchange) stops being counted even
702
+ -- though its billed usage still shows up in Cost/Tokens.
703
+ CASE WHEN e.conversation_id IS NULL THEN NULL
704
+ ELSE (
705
+ SELECT COUNT(*) FROM messages AS m2
706
+ WHERE m2.conversation_id = e.conversation_id
707
+ AND m2.role = 'user'
708
+ AND m2.content NOT LIKE '%"type":"tool\\_result"%' ESCAPE '\\'
709
+ AND m2.content NOT LIKE '%"type":"web\\_search\\_tool\\_result"%' ESCAPE '\\'
710
+ )
711
+ END AS turn_count
682
712
  FROM llm_usage_events e
683
713
  LEFT JOIN conversations c ON e.conversation_id = c.id
684
714
  WHERE ${where.sql}
@@ -65,6 +65,9 @@ const usageGroupBreakdownSchema = z.object({
65
65
  totalCacheReadTokens: z.number(),
66
66
  totalEstimatedCostUsd: z.number(),
67
67
  eventCount: z.number(),
68
+ // Distinct conversation turns; populated only for the conversation grouping,
69
+ // null otherwise (and for the conversation "Other" bucket).
70
+ turnCount: z.number().nullable(),
68
71
  });
69
72
 
70
73
  const usageSeriesGroupValueSchema = z.object({