oh-langfuse 0.1.24 → 0.1.26
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/README.md +1 -1
- package/bin/cli.js +84 -29
- package/codex_langfuse_notify.py +283 -64
- package/langfuse_hook.py +247 -46
- package/package.json +15 -7
- package/scripts/metrics-utils.mjs +126 -0
- package/scripts/opencode-langfuse-setup.mjs +233 -45
- package/scripts/real-self-verify.mjs +148 -8
- package/scripts/update-langfuse-runtime.mjs +178 -0
- package/scripts/update-utils.mjs +20 -0
package/langfuse_hook.py
CHANGED
|
@@ -4,11 +4,12 @@ Claude Code -> Langfuse hook
|
|
|
4
4
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import hashlib
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
@@ -34,8 +35,9 @@ LOG_FILE = STATE_DIR / "langfuse_hook.log"
|
|
|
34
35
|
STATE_FILE = STATE_DIR / "langfuse_state.json"
|
|
35
36
|
LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
36
37
|
|
|
37
|
-
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
38
|
-
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
38
|
+
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
39
|
+
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
40
|
+
METRICS_SCHEMA_VERSION = "1.0"
|
|
39
41
|
|
|
40
42
|
# ----------------- Logging -----------------
|
|
41
43
|
def _log(level: str, message: str) -> None:
|
|
@@ -237,14 +239,157 @@ def extract_text(content: Any) -> str:
|
|
|
237
239
|
return "\n".join([p for p in parts if p])
|
|
238
240
|
return ""
|
|
239
241
|
|
|
240
|
-
def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
|
|
242
|
+
def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
|
|
241
243
|
if s is None:
|
|
242
244
|
return "", {"truncated": False, "orig_len": 0}
|
|
243
245
|
orig_len = len(s)
|
|
244
246
|
if orig_len <= max_chars:
|
|
245
247
|
return s, {"truncated": False, "orig_len": orig_len}
|
|
246
|
-
head = s[:max_chars]
|
|
247
|
-
return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
|
|
248
|
+
head = s[:max_chars]
|
|
249
|
+
return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
|
|
250
|
+
|
|
251
|
+
def build_interaction_id(source: str, session_id: str, turn_number: int) -> str:
|
|
252
|
+
return f"{source or 'unknown'}:{session_id or 'unknown'}:{int(turn_number or 0)}"
|
|
253
|
+
|
|
254
|
+
def _num_or_none(value: Any) -> Optional[int]:
|
|
255
|
+
if isinstance(value, bool):
|
|
256
|
+
return None
|
|
257
|
+
if isinstance(value, int) and value >= 0:
|
|
258
|
+
return value
|
|
259
|
+
if isinstance(value, float) and value >= 0:
|
|
260
|
+
return int(value)
|
|
261
|
+
if isinstance(value, str):
|
|
262
|
+
try:
|
|
263
|
+
n = int(value)
|
|
264
|
+
return n if n >= 0 else None
|
|
265
|
+
except Exception:
|
|
266
|
+
return None
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def _first_num(raw: Dict[str, Any], *keys: str) -> Optional[int]:
|
|
270
|
+
for key in keys:
|
|
271
|
+
if key in raw:
|
|
272
|
+
value = _num_or_none(raw.get(key))
|
|
273
|
+
if value is not None:
|
|
274
|
+
return value
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def normalize_token_metrics(raw: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
278
|
+
if not isinstance(raw, dict) or not raw:
|
|
279
|
+
return {
|
|
280
|
+
"token_metrics_available": False,
|
|
281
|
+
"input_tokens": None,
|
|
282
|
+
"output_tokens": None,
|
|
283
|
+
"total_tokens": None,
|
|
284
|
+
"cache_read_tokens": None,
|
|
285
|
+
"reasoning_tokens": None,
|
|
286
|
+
}
|
|
287
|
+
input_tokens = _first_num(raw, "input", "input_tokens", "inputTokens")
|
|
288
|
+
output_tokens = _first_num(raw, "output", "output_tokens", "outputTokens")
|
|
289
|
+
total_tokens = _first_num(raw, "total", "total_tokens", "totalTokens")
|
|
290
|
+
if total_tokens is None and input_tokens is not None and output_tokens is not None:
|
|
291
|
+
total_tokens = input_tokens + output_tokens
|
|
292
|
+
cache_read_tokens = _first_num(raw, "cache_read_tokens", "cachedInputTokens", "cacheRead")
|
|
293
|
+
reasoning_tokens = _first_num(raw, "reasoning_tokens", "reasoningTokens", "reasoning")
|
|
294
|
+
available = any(v is not None for v in [input_tokens, output_tokens, total_tokens, cache_read_tokens, reasoning_tokens])
|
|
295
|
+
return {
|
|
296
|
+
"token_metrics_available": available,
|
|
297
|
+
"input_tokens": input_tokens if available else None,
|
|
298
|
+
"output_tokens": output_tokens if available else None,
|
|
299
|
+
"total_tokens": total_tokens if available else None,
|
|
300
|
+
"cache_read_tokens": cache_read_tokens if available else None,
|
|
301
|
+
"reasoning_tokens": reasoning_tokens if available else None,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def _ratio(numerator: Optional[int], denominator: Optional[int]) -> Optional[float]:
|
|
305
|
+
if numerator is None or denominator in (None, 0):
|
|
306
|
+
return None
|
|
307
|
+
return numerator / denominator
|
|
308
|
+
|
|
309
|
+
def build_interaction_metadata(
|
|
310
|
+
source: str,
|
|
311
|
+
user_id: Optional[str],
|
|
312
|
+
session_id: str,
|
|
313
|
+
turn_number: int,
|
|
314
|
+
token_metrics: Optional[Dict[str, Any]],
|
|
315
|
+
tool_call_count: int,
|
|
316
|
+
tool_result_count: int,
|
|
317
|
+
skill_use_count: int,
|
|
318
|
+
model: Optional[str],
|
|
319
|
+
user_message_count: int = 1,
|
|
320
|
+
assistant_message_count: int = 1,
|
|
321
|
+
) -> Dict[str, Any]:
|
|
322
|
+
tokens = normalize_token_metrics(token_metrics)
|
|
323
|
+
interaction_id = build_interaction_id(source, session_id, turn_number)
|
|
324
|
+
return {
|
|
325
|
+
"source": source,
|
|
326
|
+
"user_id": user_id or "",
|
|
327
|
+
"session_id": session_id,
|
|
328
|
+
"interaction_id": interaction_id,
|
|
329
|
+
"metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
330
|
+
"interaction_count": 1,
|
|
331
|
+
"user_message_count": user_message_count,
|
|
332
|
+
"assistant_message_count": assistant_message_count,
|
|
333
|
+
"tool_call_count": int(tool_call_count or 0),
|
|
334
|
+
"tool_result_count": int(tool_result_count or 0),
|
|
335
|
+
"skill_use_count": int(skill_use_count or 0),
|
|
336
|
+
**tokens,
|
|
337
|
+
"model": model,
|
|
338
|
+
"turn_number": int(turn_number or 0),
|
|
339
|
+
"efficiency": {
|
|
340
|
+
"tokens_per_interaction": tokens.get("total_tokens"),
|
|
341
|
+
"tool_calls_per_interaction": int(tool_call_count or 0),
|
|
342
|
+
"skills_per_interaction": int(skill_use_count or 0),
|
|
343
|
+
"output_input_token_ratio": _ratio(tokens.get("output_tokens"), tokens.get("input_tokens")),
|
|
344
|
+
"tokens_per_tool_call": _ratio(tokens.get("total_tokens"), int(tool_call_count or 0)),
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
|
|
349
|
+
roots = [
|
|
350
|
+
Path.home() / ".codex" / "skills",
|
|
351
|
+
Path.home() / ".claude" / "skills",
|
|
352
|
+
Path.home() / ".config" / "opencode" / "skill",
|
|
353
|
+
]
|
|
354
|
+
if extra_roots:
|
|
355
|
+
roots.extend(extra_roots)
|
|
356
|
+
names = set()
|
|
357
|
+
for root in roots:
|
|
358
|
+
try:
|
|
359
|
+
if not root.exists():
|
|
360
|
+
continue
|
|
361
|
+
for skill_file in root.rglob("SKILL.md"):
|
|
362
|
+
names.add(skill_file.parent.name)
|
|
363
|
+
except Exception:
|
|
364
|
+
continue
|
|
365
|
+
return names
|
|
366
|
+
|
|
367
|
+
def _skill_namespace(name: str) -> str:
|
|
368
|
+
return name.split(":", 1)[0] if ":" in name else ""
|
|
369
|
+
|
|
370
|
+
def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
|
|
371
|
+
found: Dict[str, str] = {}
|
|
372
|
+
for call in tool_calls or []:
|
|
373
|
+
tool_name = str(call.get("name") or "")
|
|
374
|
+
input_obj = call.get("input") if isinstance(call.get("input"), (dict, list, str)) else {}
|
|
375
|
+
if tool_name.lower() == "skill" and isinstance(input_obj, dict):
|
|
376
|
+
for key in ("skill_name", "skill", "name"):
|
|
377
|
+
value = input_obj.get(key)
|
|
378
|
+
if isinstance(value, str) and value.strip():
|
|
379
|
+
found[value.strip()] = "tool_call"
|
|
380
|
+
break
|
|
381
|
+
try:
|
|
382
|
+
text = json.dumps(input_obj, ensure_ascii=False)
|
|
383
|
+
except Exception:
|
|
384
|
+
text = str(input_obj)
|
|
385
|
+
for match in re.finditer(r"([A-Za-z]:)?[^\"'\n\r]*[\\/]+([^\\/\"'\n\r]+)[\\/]+SKILL\.md", text, re.IGNORECASE):
|
|
386
|
+
candidate = match.group(2)
|
|
387
|
+
if candidate and (candidate in known_skills or not known_skills):
|
|
388
|
+
found[candidate] = "skill_file_path"
|
|
389
|
+
return [
|
|
390
|
+
{"name": name, "skill_namespace": _skill_namespace(name), "detected_by": detected_by}
|
|
391
|
+
for name, detected_by in sorted(found.items())
|
|
392
|
+
]
|
|
248
393
|
|
|
249
394
|
def get_model(msg: Dict[str, Any]) -> str:
|
|
250
395
|
m = msg.get("message")
|
|
@@ -461,13 +606,31 @@ def emit_turn(
|
|
|
461
606
|
assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
|
|
462
607
|
|
|
463
608
|
model = get_model(turn.assistant_msgs[0])
|
|
464
|
-
usage_details = get_usage(last_assistant)
|
|
465
|
-
|
|
466
|
-
tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
609
|
+
usage_details = get_usage(last_assistant)
|
|
610
|
+
|
|
611
|
+
tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
|
|
612
|
+
skill_usages = detect_skill_usages(tool_calls, discover_known_skills())
|
|
613
|
+
interaction_meta = build_interaction_metadata(
|
|
614
|
+
"claude",
|
|
615
|
+
user_id,
|
|
616
|
+
session_id,
|
|
617
|
+
turn_num,
|
|
618
|
+
usage_details,
|
|
619
|
+
len(tool_calls),
|
|
620
|
+
len(turn.tool_results_by_id),
|
|
621
|
+
len(skill_usages),
|
|
622
|
+
model,
|
|
623
|
+
user_message_count=1,
|
|
624
|
+
assistant_message_count=len(turn.assistant_msgs),
|
|
625
|
+
)
|
|
626
|
+
skill_summary = [
|
|
627
|
+
{"name": item["name"], "count": 1, "detected_by": item["detected_by"]}
|
|
628
|
+
for item in skill_usages
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
# attach tool outputs
|
|
632
|
+
for c in tool_calls:
|
|
633
|
+
if c["id"] and c["id"] in turn.tool_results_by_id:
|
|
471
634
|
out_raw = turn.tool_results_by_id[c["id"]]
|
|
472
635
|
out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
|
|
473
636
|
out_trunc, out_meta = truncate_text(out_str)
|
|
@@ -484,33 +647,65 @@ def emit_turn(
|
|
|
484
647
|
):
|
|
485
648
|
with langfuse.start_as_current_observation(
|
|
486
649
|
name=f"Claude Code - Turn {turn_num}",
|
|
487
|
-
input={"role": "user", "content": user_text},
|
|
488
|
-
metadata={
|
|
489
|
-
|
|
490
|
-
"
|
|
491
|
-
"
|
|
492
|
-
"
|
|
493
|
-
"
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
650
|
+
input={"role": "user", "content": user_text},
|
|
651
|
+
metadata={
|
|
652
|
+
**interaction_meta,
|
|
653
|
+
"source": "claude",
|
|
654
|
+
"session_id": session_id,
|
|
655
|
+
"turn_number": turn_num,
|
|
656
|
+
"transcript_path": str(transcript_path),
|
|
657
|
+
"user_text": user_text_meta,
|
|
658
|
+
"skills": skill_summary,
|
|
659
|
+
},
|
|
660
|
+
) as trace_span:
|
|
661
|
+
with langfuse.start_as_current_observation(
|
|
662
|
+
name="AI Interaction",
|
|
663
|
+
input={"role": "user", "content": user_text},
|
|
664
|
+
output={"role": "assistant", "content": assistant_text},
|
|
665
|
+
metadata=interaction_meta,
|
|
666
|
+
):
|
|
667
|
+
pass
|
|
668
|
+
|
|
669
|
+
# LLM generation
|
|
670
|
+
with langfuse.start_as_current_observation(
|
|
671
|
+
name="Claude Response",
|
|
499
672
|
as_type="generation",
|
|
500
673
|
model=model,
|
|
501
674
|
input={"role": "user", "content": user_text},
|
|
502
675
|
output={"role": "assistant", "content": assistant_text},
|
|
503
676
|
usage_details=usage_details or None,
|
|
504
677
|
metadata={
|
|
505
|
-
"assistant_text": assistant_text_meta,
|
|
506
|
-
"tool_count": len(tool_calls),
|
|
507
|
-
"usage_details": usage_details,
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
678
|
+
"assistant_text": assistant_text_meta,
|
|
679
|
+
"tool_count": len(tool_calls),
|
|
680
|
+
"usage_details": usage_details,
|
|
681
|
+
"source": "claude",
|
|
682
|
+
"user_id": user_id or "",
|
|
683
|
+
"session_id": session_id,
|
|
684
|
+
"interaction_id": interaction_meta["interaction_id"],
|
|
685
|
+
"turn_number": turn_num,
|
|
686
|
+
},
|
|
687
|
+
):
|
|
688
|
+
pass
|
|
689
|
+
|
|
690
|
+
for skill in skill_usages:
|
|
691
|
+
with langfuse.start_as_current_observation(
|
|
692
|
+
name=f"Skill Use: {skill['name']}",
|
|
693
|
+
metadata={
|
|
694
|
+
"source": "claude",
|
|
695
|
+
"user_id": user_id or "",
|
|
696
|
+
"session_id": session_id,
|
|
697
|
+
"interaction_id": interaction_meta["interaction_id"],
|
|
698
|
+
"skill_name": skill["name"],
|
|
699
|
+
"skill_namespace": skill["skill_namespace"],
|
|
700
|
+
"detected_by": skill["detected_by"],
|
|
701
|
+
"turn_number": turn_num,
|
|
702
|
+
"metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
703
|
+
},
|
|
704
|
+
):
|
|
705
|
+
pass
|
|
706
|
+
|
|
707
|
+
# Tool observations
|
|
708
|
+
for tc in tool_calls:
|
|
514
709
|
in_obj = tc["input"]
|
|
515
710
|
# truncate tool input if it's a large string payload
|
|
516
711
|
if isinstance(in_obj, str):
|
|
@@ -522,14 +717,20 @@ def emit_turn(
|
|
|
522
717
|
name=f"Tool: {tc['name']}",
|
|
523
718
|
as_type="tool",
|
|
524
719
|
input=in_obj,
|
|
525
|
-
metadata={
|
|
526
|
-
"
|
|
527
|
-
"
|
|
528
|
-
"
|
|
529
|
-
"
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
720
|
+
metadata={
|
|
721
|
+
"source": "claude",
|
|
722
|
+
"user_id": user_id or "",
|
|
723
|
+
"session_id": session_id,
|
|
724
|
+
"interaction_id": interaction_meta["interaction_id"],
|
|
725
|
+
"tool_name": tc["name"],
|
|
726
|
+
"tool_id": tc["id"],
|
|
727
|
+
"turn_number": turn_num,
|
|
728
|
+
"input_meta": in_meta,
|
|
729
|
+
"output_meta": tc.get("output_meta"),
|
|
730
|
+
"metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
731
|
+
},
|
|
732
|
+
) as tool_obs:
|
|
733
|
+
tool_obs.update(output=tc.get("output"))
|
|
533
734
|
|
|
534
735
|
trace_span.update(output={"role": "assistant", "content": assistant_text})
|
|
535
736
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-langfuse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
7
|
-
"
|
|
6
|
+
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=16"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
8
11
|
"oh-langfuse": "bin/cli.js",
|
|
9
12
|
"code-tool-langfuse": "bin/cli.js"
|
|
10
13
|
},
|
|
@@ -18,8 +21,11 @@
|
|
|
18
21
|
"scripts/opencode-langfuse-check.mjs",
|
|
19
22
|
"scripts/opencode-langfuse-run.mjs",
|
|
20
23
|
"scripts/opencode-langfuse-setup.mjs",
|
|
21
|
-
"scripts/resolve-opencode-cli.mjs",
|
|
24
|
+
"scripts/resolve-opencode-cli.mjs",
|
|
22
25
|
"scripts/real-self-verify.mjs",
|
|
26
|
+
"scripts/metrics-utils.mjs",
|
|
27
|
+
"scripts/update-langfuse-runtime.mjs",
|
|
28
|
+
"scripts/update-utils.mjs",
|
|
23
29
|
"langfuse_hook.py",
|
|
24
30
|
"codex_langfuse_notify.py",
|
|
25
31
|
"README.md",
|
|
@@ -28,9 +34,10 @@
|
|
|
28
34
|
"setup-langfuse.bat",
|
|
29
35
|
"setup-langfuse.sh"
|
|
30
36
|
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"start": "node bin/cli.js",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "node bin/cli.js",
|
|
33
39
|
"check": "node --check bin/cli.js",
|
|
40
|
+
"test": "node --test tests/*.test.mjs",
|
|
34
41
|
"pack:check": "npm pack --dry-run",
|
|
35
42
|
"claude:setup": "node scripts/langfuse-setup.mjs",
|
|
36
43
|
"claude:check": "node scripts/langfuse-check.mjs",
|
|
@@ -44,8 +51,9 @@
|
|
|
44
51
|
"opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
|
|
45
52
|
"codex:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
46
53
|
"codex:check": "node scripts/codex-langfuse-check.mjs",
|
|
47
|
-
"codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
54
|
+
"codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
48
55
|
"codex:langfuse:check": "node scripts/codex-langfuse-check.mjs",
|
|
56
|
+
"update": "node scripts/update-langfuse-runtime.mjs",
|
|
49
57
|
"self:verify": "node scripts/real-self-verify.mjs"
|
|
50
58
|
},
|
|
51
59
|
"dependencies": {}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export const METRICS_SCHEMA_VERSION = "1.0";
|
|
2
|
+
|
|
3
|
+
function numberOrNull(value) {
|
|
4
|
+
if (typeof value === "string" && value.trim().startsWith("{")) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = JSON.parse(value);
|
|
7
|
+
return numberOrNull(parsed.intValue ?? parsed.doubleValue ?? parsed.value);
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildInteractionId(source, sessionId, turnNumber) {
|
|
17
|
+
return `${String(source || "unknown")}:${String(sessionId || "unknown")}:${Number(turnNumber) || 0}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeTokenMetrics(raw) {
|
|
21
|
+
if (!raw || typeof raw !== "object") {
|
|
22
|
+
return {
|
|
23
|
+
token_metrics_available: false,
|
|
24
|
+
input_tokens: null,
|
|
25
|
+
output_tokens: null,
|
|
26
|
+
total_tokens: null,
|
|
27
|
+
cache_read_tokens: null,
|
|
28
|
+
reasoning_tokens: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const input = numberOrNull(raw.input ?? raw.input_tokens ?? raw.inputTokens);
|
|
33
|
+
const output = numberOrNull(raw.output ?? raw.output_tokens ?? raw.outputTokens);
|
|
34
|
+
const total = numberOrNull(raw.total ?? raw.total_tokens ?? raw.totalTokens ?? (input != null && output != null ? input + output : null));
|
|
35
|
+
const cacheRead = numberOrNull(raw.cacheRead ?? raw.cache_read_tokens ?? raw.cachedInputTokens);
|
|
36
|
+
const reasoning = numberOrNull(raw.reasoning ?? raw.reasoning_tokens ?? raw.reasoningTokens);
|
|
37
|
+
|
|
38
|
+
const available = [input, output, total, cacheRead, reasoning].some((value) => value != null);
|
|
39
|
+
return {
|
|
40
|
+
token_metrics_available: available,
|
|
41
|
+
input_tokens: available ? input : null,
|
|
42
|
+
output_tokens: available ? output : null,
|
|
43
|
+
total_tokens: available ? total : null,
|
|
44
|
+
cache_read_tokens: available ? cacheRead : null,
|
|
45
|
+
reasoning_tokens: available ? reasoning : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ratio(numerator, denominator) {
|
|
50
|
+
if (numerator == null || denominator == null || denominator === 0) return null;
|
|
51
|
+
return numerator / denominator;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildInteractionMetadata(options = {}) {
|
|
55
|
+
const source = String(options.source || "unknown");
|
|
56
|
+
const sessionId = String(options.sessionId || options.session_id || "unknown");
|
|
57
|
+
const turnNumber = Number(options.turnNumber ?? options.turn_number ?? 0) || 0;
|
|
58
|
+
const tokenMetrics = normalizeTokenMetrics(options.tokenMetrics);
|
|
59
|
+
const toolCallCount = Number(options.toolCallCount ?? options.tool_call_count ?? 0) || 0;
|
|
60
|
+
const toolResultCount = Number(options.toolResultCount ?? options.tool_result_count ?? 0) || 0;
|
|
61
|
+
const skillUseCount = Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
source,
|
|
65
|
+
user_id: String(options.userId || options.user_id || ""),
|
|
66
|
+
session_id: sessionId,
|
|
67
|
+
interaction_id: options.interactionId || buildInteractionId(source, sessionId, turnNumber),
|
|
68
|
+
metrics_schema_version: METRICS_SCHEMA_VERSION,
|
|
69
|
+
interaction_count: 1,
|
|
70
|
+
user_message_count: Number(options.userMessageCount ?? options.user_message_count ?? 1) || 1,
|
|
71
|
+
assistant_message_count: Number(options.assistantMessageCount ?? options.assistant_message_count ?? 1) || 1,
|
|
72
|
+
tool_call_count: toolCallCount,
|
|
73
|
+
tool_result_count: toolResultCount,
|
|
74
|
+
skill_use_count: skillUseCount,
|
|
75
|
+
...tokenMetrics,
|
|
76
|
+
model: options.model || null,
|
|
77
|
+
turn_number: turnNumber,
|
|
78
|
+
efficiency: {
|
|
79
|
+
tokens_per_interaction: tokenMetrics.total_tokens,
|
|
80
|
+
tool_calls_per_interaction: toolCallCount,
|
|
81
|
+
skills_per_interaction: skillUseCount,
|
|
82
|
+
output_input_token_ratio: ratio(tokenMetrics.output_tokens, tokenMetrics.input_tokens),
|
|
83
|
+
tokens_per_tool_call: ratio(tokenMetrics.total_tokens, toolCallCount),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildOpencodeMetricAttributes(options = {}) {
|
|
89
|
+
const attrs = options.attributes || {};
|
|
90
|
+
const userId = String(options.userId || attrs["oh.langfuse.user_id"] || attrs["langfuse.user.id"] || "");
|
|
91
|
+
const sessionId = String(attrs["ai.request.headers.x-opencode-session"] || options.sessionId || options.session_id || "unknown");
|
|
92
|
+
const requestId = String(attrs["ai.request.headers.x-opencode-request"] || options.requestId || options.request_id || "unknown");
|
|
93
|
+
const spanId = String(options.spanId || attrs["span.id"] || "unknown");
|
|
94
|
+
const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";
|
|
95
|
+
const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";
|
|
96
|
+
const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || null;
|
|
97
|
+
const tokenMetrics = normalizeTokenMetrics({
|
|
98
|
+
input: attrs["ai.usage.inputTokens"] ?? attrs["ai.usage.promptTokens"] ?? attrs["gen_ai.usage.input_tokens"],
|
|
99
|
+
output: attrs["ai.usage.outputTokens"] ?? attrs["ai.usage.completionTokens"] ?? attrs["gen_ai.usage.output_tokens"],
|
|
100
|
+
total: attrs["ai.usage.totalTokens"],
|
|
101
|
+
cacheRead: attrs["ai.usage.cachedInputTokens"] ?? attrs["ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
102
|
+
reasoning: attrs["ai.usage.reasoningTokens"] ?? attrs["ai.usage.outputTokenDetails.reasoningTokens"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const out = {
|
|
106
|
+
"langfuse.observation.metadata.source": "opencode",
|
|
107
|
+
"langfuse.observation.metadata.user_id": userId,
|
|
108
|
+
"langfuse.observation.metadata.session_id": sessionId,
|
|
109
|
+
"langfuse.observation.metadata.interaction_id": `opencode:${userId || "unknown"}:${sessionId}:${requestId}:${spanId}`,
|
|
110
|
+
"langfuse.observation.metadata.metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
111
|
+
"langfuse.observation.metadata.interaction_count": 1,
|
|
112
|
+
"langfuse.observation.metadata.user_message_count": 1,
|
|
113
|
+
"langfuse.observation.metadata.assistant_message_count": 1,
|
|
114
|
+
"langfuse.observation.metadata.tool_call_count": 0,
|
|
115
|
+
"langfuse.observation.metadata.tool_result_count": 0,
|
|
116
|
+
"langfuse.observation.metadata.skill_use_count": 0,
|
|
117
|
+
"langfuse.observation.metadata.token_metrics_available": tokenMetrics.token_metrics_available,
|
|
118
|
+
"langfuse.observation.metadata.model": model,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const key of ["input_tokens", "output_tokens", "total_tokens", "cache_read_tokens", "reasoning_tokens"]) {
|
|
122
|
+
if (tokenMetrics[key] != null) out[`langfuse.observation.metadata.${key}`] = tokenMetrics[key];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return out;
|
|
126
|
+
}
|