octarin-cli 0.3.2 → 0.3.4

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.
@@ -290,6 +290,14 @@ def parse_claude_transcript( # noqa: PLR0915 - top-down jsonl parser; splitting
290
290
  :_OUTPUT_CAP
291
291
  ],
292
292
  "status": "ok",
293
+ # subagent turns bill against the session model;
294
+ # carry it so per-model analytics can attribute
295
+ # their tokens
296
+ **(
297
+ {"model": model}
298
+ if stype == "agent" and model
299
+ else {}
300
+ ),
293
301
  }
294
302
  )
295
303
  prev_ts = ts
@@ -347,6 +347,11 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
347
347
 
348
348
  pending_user_text = ""
349
349
  pending_user_attachments: list[dict] = []
350
+ # One API generation streams as SEVERAL transcript entries (one per content
351
+ # block — text, then each tool_use) that share the same message id and each
352
+ # repeat the generation's FULL usage. Merge them into ONE span keyed by that
353
+ # id: usage/cost counted once, outputs concatenated, tool children attached.
354
+ llm_span_by_id: dict[str, dict] = {}
350
355
  # ts of the previous transcript entry; the LLM call started when the user
351
356
  # prompt / tool result landed, finished when the assistant message appears.
352
357
  prev_ts: str | None = None
@@ -373,6 +378,31 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
373
378
  span_id = _msg(entry).get("id") or uuid.uuid4().hex
374
379
  out_text = _truncate(_text(content))
375
380
 
381
+ existing = llm_span_by_id.get(str(span_id))
382
+ if existing is not None:
383
+ # Continuation entry of an already-seen generation: extend the span,
384
+ # never re-count its usage (each entry repeats the full totals).
385
+ existing["end_time"] = ts
386
+ if out_text:
387
+ joined = (
388
+ f"{existing['output']}\n{out_text}" if existing["output"] else out_text
389
+ )
390
+ existing["output"] = _truncate(joined)
391
+ for tu in _blocks(content, "tool_use"):
392
+ _append_tool_span(
393
+ spans,
394
+ totals,
395
+ tu,
396
+ parent_span_id=str(span_id),
397
+ ts=ts,
398
+ results_by_id=results_by_id,
399
+ errors_by_id=errors_by_id,
400
+ result_ts_by_id=result_ts_by_id,
401
+ attachments_by_tool_id=attachments_by_tool_id,
402
+ )
403
+ prev_ts = ts
404
+ continue
405
+
376
406
  in_tok = usage.get("input", 0)
377
407
  out_tok = usage.get("output", 0)
378
408
  cache_r = usage.get("cache_read", 0)
@@ -399,6 +429,7 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
399
429
  if pending_user_attachments:
400
430
  llm_span["attachments"] = pending_user_attachments
401
431
  spans.append(llm_span)
432
+ llm_span_by_id[str(span_id)] = llm_span
402
433
  pending_user_text = "" # consumed by this generation
403
434
  pending_user_attachments = [] # consumed by this generation
404
435
 
@@ -408,29 +439,17 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
408
439
  totals["total_tokens"] += in_tok + out_tok
409
440
 
410
441
  for tu in _blocks(content, "tool_use"):
411
- tid = str(tu.get("id") or uuid.uuid4().hex)
412
- tname = tu.get("name") or "unknown"
413
- tu_input = tu.get("input")
414
- input_str = (
415
- tu_input if isinstance(tu_input, str) else json.dumps(tu_input, ensure_ascii=False)
442
+ _append_tool_span(
443
+ spans,
444
+ totals,
445
+ tu,
446
+ parent_span_id=str(span_id),
447
+ ts=ts,
448
+ results_by_id=results_by_id,
449
+ errors_by_id=errors_by_id,
450
+ result_ts_by_id=result_ts_by_id,
451
+ attachments_by_tool_id=attachments_by_tool_id,
416
452
  )
417
- tool_span = {
418
- "span_id": tid,
419
- "parent_span_id": str(span_id),
420
- "name": f"Tool: {tname}",
421
- "span_type": "tool",
422
- "start_time": ts,
423
- "end_time": result_ts_by_id.get(tid, ts),
424
- "input": _truncate(input_str),
425
- "output": _truncate(results_by_id.get(tid, "")) or None,
426
- "status": "error" if errors_by_id.get(tid) else "ok",
427
- "attributes": {"tool_name": tname, "tool_id": tid},
428
- }
429
- tool_atts = attachments_by_tool_id.get(tid)
430
- if tool_atts:
431
- tool_span["attachments"] = tool_atts
432
- spans.append(tool_span)
433
- totals["tool_call_count"] += 1
434
453
 
435
454
  prev_ts = ts
436
455
 
@@ -438,6 +457,42 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
438
457
  return spans, totals, models, None
439
458
 
440
459
 
460
+ def _append_tool_span(
461
+ spans: list[dict],
462
+ totals: dict,
463
+ tu: dict,
464
+ *,
465
+ parent_span_id: str,
466
+ ts: str,
467
+ results_by_id: dict[str, str],
468
+ errors_by_id: dict[str, bool],
469
+ result_ts_by_id: dict[str, str],
470
+ attachments_by_tool_id: dict[str, list[dict]],
471
+ ) -> None:
472
+ """Append one ``tool`` child span for a ``tool_use`` block to ``spans``."""
473
+ tid = str(tu.get("id") or uuid.uuid4().hex)
474
+ tname = tu.get("name") or "unknown"
475
+ tu_input = tu.get("input")
476
+ input_str = tu_input if isinstance(tu_input, str) else json.dumps(tu_input, ensure_ascii=False)
477
+ tool_span = {
478
+ "span_id": tid,
479
+ "parent_span_id": parent_span_id,
480
+ "name": f"Tool: {tname}",
481
+ "span_type": "tool",
482
+ "start_time": ts,
483
+ "end_time": result_ts_by_id.get(tid, ts),
484
+ "input": _truncate(input_str),
485
+ "output": _truncate(results_by_id.get(tid, "")) or None,
486
+ "status": "error" if errors_by_id.get(tid) else "ok",
487
+ "attributes": {"tool_name": tname, "tool_id": tid},
488
+ }
489
+ tool_atts = attachments_by_tool_id.get(tid)
490
+ if tool_atts:
491
+ tool_span["attachments"] = tool_atts
492
+ spans.append(tool_span)
493
+ totals["tool_call_count"] += 1
494
+
495
+
441
496
  def user_ref() -> str:
442
497
  """Resolve the engineer's real identity for attribution.
443
498
 
@@ -128,6 +128,9 @@ function toolSpanId(input) {
128
128
  function fromPostToolUse(input) {
129
129
  const tool = input.tool_name || "tool";
130
130
  const span = baseSpan(toolSpanId(input), tool, "tool");
131
+ // Cursor never exposes token usage, so the backend estimates tool spans from
132
+ // their I/O — stamp the session model so the estimate prices at its real rate.
133
+ span.model = input.model || null;
131
134
  span.input = truncate(asText(input.tool_input));
132
135
  span.output = truncate(asText(input.tool_output));
133
136
  span.attributes = {
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
144
147
  function fromPostToolUseFailure(input) {
145
148
  const tool = input.tool_name || "tool";
146
149
  const span = baseSpan(toolSpanId(input), tool, "tool");
150
+ span.model = input.model || null;
147
151
  span.status = "error";
148
152
  span.error_message = input.error_message || input.failure_type || "tool failed";
149
153
  span.input = truncate(asText(input.tool_input));
@@ -128,6 +128,9 @@ function toolSpanId(input) {
128
128
  function fromPostToolUse(input) {
129
129
  const tool = input.tool_name || "tool";
130
130
  const span = baseSpan(toolSpanId(input), tool, "tool");
131
+ // Cursor never exposes token usage, so the backend estimates tool spans from
132
+ // their I/O — stamp the session model so the estimate prices at its real rate.
133
+ span.model = input.model || null;
131
134
  span.input = truncate(asText(input.tool_input));
132
135
  span.output = truncate(asText(input.tool_output));
133
136
  span.attributes = {
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
144
147
  function fromPostToolUseFailure(input) {
145
148
  const tool = input.tool_name || "tool";
146
149
  const span = baseSpan(toolSpanId(input), tool, "tool");
150
+ span.model = input.model || null;
147
151
  span.status = "error";
148
152
  span.error_message = input.error_message || input.failure_type || "tool failed";
149
153
  span.input = truncate(asText(input.tool_input));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octarin-cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Octarin's per-user CLI: install AI-coding capture (`octarin init` / `init-repo`) and authorize a machine (`octarin login`). Streams your Claude Code / Cursor / Codex usage to your Octarin workspace.",
5
5
  "keywords": [
6
6
  "octarin",