oh-langfuse 0.1.59 → 0.1.61
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 +245 -245
- package/bin/cli.js +22 -22
- package/codex_langfuse_notify.py +422 -56
- package/package.json +6 -6
- package/scripts/auto-update-runtime.mjs +28 -15
- package/scripts/codex-langfuse-setup.mjs +127 -117
- package/scripts/langfuse-setup.mjs +71 -71
- package/scripts/opencode-langfuse-run.mjs +23 -23
- package/scripts/opencode-langfuse-setup.mjs +140 -64
- package/scripts/real-self-verify.mjs +114 -104
- package/scripts/update-langfuse-runtime.mjs +7 -6
- package/scripts/verify-update-runtime.mjs +162 -162
- package/scripts/verify-update-utils.mjs +60 -60
package/codex_langfuse_notify.py
CHANGED
|
@@ -13,42 +13,42 @@ import re
|
|
|
13
13
|
import sys
|
|
14
14
|
import time
|
|
15
15
|
import hashlib
|
|
16
|
-
from dataclasses import dataclass
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
-
from urllib.parse import urlparse
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def codex_langfuse_config_path() -> Path:
|
|
24
|
-
codex_dir = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
25
|
-
return codex_dir / "langfuse" / "config.json"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def read_langfuse_base_url_from_config() -> Optional[str]:
|
|
29
|
-
try:
|
|
30
|
-
path = codex_langfuse_config_path()
|
|
31
|
-
if not path.exists():
|
|
32
|
-
return None
|
|
33
|
-
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
34
|
-
if isinstance(data, dict):
|
|
35
|
-
value = data.get("baseUrl") or data.get("host")
|
|
36
|
-
if isinstance(value, str) and value.strip():
|
|
37
|
-
return value.strip()
|
|
38
|
-
except Exception:
|
|
39
|
-
return None
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def configure_langfuse_no_proxy() -> None:
|
|
44
|
-
hosts = ["localhost", "127.0.0.1"]
|
|
45
|
-
values = [
|
|
46
|
-
*(os.environ.get(key) for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CODEX_LANGFUSE_BASE_URL")),
|
|
47
|
-
read_langfuse_base_url_from_config(),
|
|
48
|
-
]
|
|
49
|
-
for value in values:
|
|
50
|
-
if not value:
|
|
51
|
-
continue
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def codex_langfuse_config_path() -> Path:
|
|
24
|
+
codex_dir = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
25
|
+
return codex_dir / "langfuse" / "config.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_langfuse_base_url_from_config() -> Optional[str]:
|
|
29
|
+
try:
|
|
30
|
+
path = codex_langfuse_config_path()
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return None
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
34
|
+
if isinstance(data, dict):
|
|
35
|
+
value = data.get("baseUrl") or data.get("host")
|
|
36
|
+
if isinstance(value, str) and value.strip():
|
|
37
|
+
return value.strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_langfuse_no_proxy() -> None:
|
|
44
|
+
hosts = ["localhost", "127.0.0.1"]
|
|
45
|
+
values = [
|
|
46
|
+
*(os.environ.get(key) for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CODEX_LANGFUSE_BASE_URL")),
|
|
47
|
+
read_langfuse_base_url_from_config(),
|
|
48
|
+
]
|
|
49
|
+
for value in values:
|
|
50
|
+
if not value:
|
|
51
|
+
continue
|
|
52
52
|
parsed = urlparse(value if "://" in value else f"http://{value}")
|
|
53
53
|
if parsed.hostname:
|
|
54
54
|
hosts.append(parsed.hostname)
|
|
@@ -85,8 +85,8 @@ LOG_FILE = STATE_DIR / "codex_langfuse_notify.log"
|
|
|
85
85
|
DEBUG = os.environ.get("CODEX_LANGFUSE_DEBUG", "").lower() == "true"
|
|
86
86
|
MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
|
|
87
87
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
88
|
-
AGENT_NAME = "codex"
|
|
89
|
-
AGENT_TURN_NAME = "Codex Agent Turn"
|
|
88
|
+
AGENT_NAME = "codex"
|
|
89
|
+
AGENT_TURN_NAME = "Codex Agent Turn"
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def log(level: str, message: str) -> None:
|
|
@@ -147,6 +147,53 @@ class SessionState:
|
|
|
147
147
|
offset: int = 0
|
|
148
148
|
buffer: str = ""
|
|
149
149
|
turn_count: int = 0
|
|
150
|
+
uploaded_turn_ids: set = field(default_factory=set)
|
|
151
|
+
uploaded_turn_fallback_keys: set = field(default_factory=set)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class CodexToolCall:
|
|
156
|
+
id: str
|
|
157
|
+
name: str
|
|
158
|
+
input: Any = None
|
|
159
|
+
output: Any = None
|
|
160
|
+
start_time: Optional[str] = None
|
|
161
|
+
end_time: Optional[str] = None
|
|
162
|
+
status: Optional[str] = None
|
|
163
|
+
error: Optional[str] = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class CodexStep:
|
|
168
|
+
start_time: Optional[str] = None
|
|
169
|
+
end_time: Optional[str] = None
|
|
170
|
+
assistant_text: str = ""
|
|
171
|
+
reasoning: str = ""
|
|
172
|
+
usage: Dict[str, Any] = field(default_factory=dict)
|
|
173
|
+
tool_calls: List[CodexToolCall] = field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class CodexTurn:
|
|
178
|
+
turn_id: Optional[str] = None
|
|
179
|
+
start_time: Optional[str] = None
|
|
180
|
+
end_time: Optional[str] = None
|
|
181
|
+
completed: bool = False
|
|
182
|
+
aborted: bool = False
|
|
183
|
+
user_text: str = ""
|
|
184
|
+
user_text_fallback: str = ""
|
|
185
|
+
final_output: str = ""
|
|
186
|
+
model: Optional[str] = None
|
|
187
|
+
invocation_params: Dict[str, Any] = field(default_factory=dict)
|
|
188
|
+
steps: List[CodexStep] = field(default_factory=list)
|
|
189
|
+
total_usage: Dict[str, Any] = field(default_factory=dict)
|
|
190
|
+
subagent_thread_ids: List[str] = field(default_factory=list)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class CodexParsedSession:
|
|
195
|
+
session_meta: Dict[str, Any]
|
|
196
|
+
turns: List[CodexTurn]
|
|
150
197
|
|
|
151
198
|
|
|
152
199
|
def read_json_if_exists(path: Path) -> Dict[str, Any]:
|
|
@@ -277,6 +324,8 @@ def load_session_state(state: Dict[str, Any], key: str) -> SessionState:
|
|
|
277
324
|
offset=int(raw.get("offset", 0) or 0),
|
|
278
325
|
buffer=str(raw.get("buffer", "") or ""),
|
|
279
326
|
turn_count=int(raw.get("turn_count", 0) or 0),
|
|
327
|
+
uploaded_turn_ids={str(item) for item in raw.get("uploaded_turn_ids", []) if item},
|
|
328
|
+
uploaded_turn_fallback_keys={str(item) for item in raw.get("uploaded_turn_fallback_keys", []) if item},
|
|
280
329
|
)
|
|
281
330
|
|
|
282
331
|
|
|
@@ -285,10 +334,31 @@ def save_session_state(state: Dict[str, Any], key: str, ss: SessionState) -> Non
|
|
|
285
334
|
"offset": ss.offset,
|
|
286
335
|
"buffer": ss.buffer,
|
|
287
336
|
"turn_count": ss.turn_count,
|
|
337
|
+
"uploaded_turn_ids": sorted(ss.uploaded_turn_ids),
|
|
338
|
+
"uploaded_turn_fallback_keys": sorted(ss.uploaded_turn_fallback_keys),
|
|
288
339
|
"updated": datetime.now(timezone.utc).isoformat(),
|
|
289
340
|
}
|
|
290
341
|
|
|
291
342
|
|
|
343
|
+
def read_all_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|
344
|
+
out: List[Dict[str, Any]] = []
|
|
345
|
+
try:
|
|
346
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
347
|
+
for line in f:
|
|
348
|
+
raw = line.strip()
|
|
349
|
+
if not raw:
|
|
350
|
+
continue
|
|
351
|
+
try:
|
|
352
|
+
item = json.loads(raw)
|
|
353
|
+
if isinstance(item, dict):
|
|
354
|
+
out.append(item)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
debug(f"jsonl row parse failed: {e}")
|
|
357
|
+
except Exception as e:
|
|
358
|
+
debug(f"read_all_jsonl failed: {e}")
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
|
|
292
362
|
def read_new_jsonl(path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
|
|
293
363
|
out: List[Dict[str, Any]] = []
|
|
294
364
|
try:
|
|
@@ -717,6 +787,27 @@ def summarize_skill_usages(skill_usages: List[Dict[str, str]]) -> List[Dict[str,
|
|
|
717
787
|
return list(summary.values())
|
|
718
788
|
|
|
719
789
|
|
|
790
|
+
def is_codex_wrapper_text(text: str) -> bool:
|
|
791
|
+
stripped = (text or "").strip()
|
|
792
|
+
return stripped.startswith("<environment_context") or stripped.startswith("<user_instructions")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def append_text(existing: str, text: str) -> str:
|
|
796
|
+
text = text or ""
|
|
797
|
+
if not text:
|
|
798
|
+
return existing or ""
|
|
799
|
+
return f"{existing}\n{text}" if existing else text
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def parse_tool_input(value: Any) -> Any:
|
|
803
|
+
if isinstance(value, str):
|
|
804
|
+
try:
|
|
805
|
+
return json.loads(value)
|
|
806
|
+
except Exception:
|
|
807
|
+
return value
|
|
808
|
+
return value
|
|
809
|
+
|
|
810
|
+
|
|
720
811
|
def get_payload(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
721
812
|
payload = row.get("payload")
|
|
722
813
|
return payload if isinstance(payload, dict) else {}
|
|
@@ -748,6 +839,183 @@ def extract_usage(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
748
839
|
return usage
|
|
749
840
|
|
|
750
841
|
|
|
842
|
+
def parse_codex_session(rows: List[Dict[str, Any]]) -> CodexParsedSession:
|
|
843
|
+
session_meta: Dict[str, Any] = {"session_path": ""}
|
|
844
|
+
turns: List[CodexTurn] = []
|
|
845
|
+
turn: Optional[CodexTurn] = None
|
|
846
|
+
step: Optional[CodexStep] = None
|
|
847
|
+
tool_calls_by_id: Dict[str, CodexToolCall] = {}
|
|
848
|
+
last_ts: Optional[str] = None
|
|
849
|
+
|
|
850
|
+
def ensure_turn(ts: Optional[str]) -> CodexTurn:
|
|
851
|
+
nonlocal turn
|
|
852
|
+
if turn is None:
|
|
853
|
+
turn = CodexTurn(start_time=ts, end_time=ts)
|
|
854
|
+
return turn
|
|
855
|
+
|
|
856
|
+
def ensure_step(ts: Optional[str]) -> CodexStep:
|
|
857
|
+
nonlocal step
|
|
858
|
+
if step is None:
|
|
859
|
+
step = CodexStep(start_time=ts, end_time=ts)
|
|
860
|
+
return step
|
|
861
|
+
|
|
862
|
+
def close_step(ts: Optional[str], usage: Optional[Dict[str, Any]] = None) -> None:
|
|
863
|
+
nonlocal step
|
|
864
|
+
if step is None or turn is None:
|
|
865
|
+
return
|
|
866
|
+
step.end_time = ts or step.end_time
|
|
867
|
+
if isinstance(usage, dict):
|
|
868
|
+
step.usage = usage
|
|
869
|
+
turn.steps.append(step)
|
|
870
|
+
step = None
|
|
871
|
+
|
|
872
|
+
def finish_turn(ts: Optional[str], completed: bool, aborted: bool) -> None:
|
|
873
|
+
nonlocal turn, step, tool_calls_by_id
|
|
874
|
+
if turn is None:
|
|
875
|
+
return
|
|
876
|
+
close_step(ts)
|
|
877
|
+
turn.end_time = ts or turn.end_time
|
|
878
|
+
turn.completed = completed
|
|
879
|
+
turn.aborted = aborted
|
|
880
|
+
if not turn.user_text:
|
|
881
|
+
turn.user_text = turn.user_text_fallback
|
|
882
|
+
if not turn.final_output:
|
|
883
|
+
for candidate in reversed(turn.steps):
|
|
884
|
+
if candidate.assistant_text:
|
|
885
|
+
turn.final_output = candidate.assistant_text
|
|
886
|
+
break
|
|
887
|
+
turns.append(turn)
|
|
888
|
+
turn = None
|
|
889
|
+
step = None
|
|
890
|
+
tool_calls_by_id = {}
|
|
891
|
+
|
|
892
|
+
for row in rows:
|
|
893
|
+
ts = str(row.get("timestamp") or last_ts or "")
|
|
894
|
+
last_ts = ts
|
|
895
|
+
row_type = row.get("type")
|
|
896
|
+
payload = get_payload(row)
|
|
897
|
+
|
|
898
|
+
if row_type == "session_meta":
|
|
899
|
+
session_meta.update(payload)
|
|
900
|
+
continue
|
|
901
|
+
|
|
902
|
+
if row_type == "turn_context":
|
|
903
|
+
t = ensure_turn(ts)
|
|
904
|
+
model = payload.get("model")
|
|
905
|
+
if isinstance(model, str) and model.strip():
|
|
906
|
+
t.model = model.strip()
|
|
907
|
+
if isinstance(payload, dict):
|
|
908
|
+
t.invocation_params = dict(payload)
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
if row_type == "response_item":
|
|
912
|
+
item_type = payload.get("type")
|
|
913
|
+
t = ensure_turn(ts)
|
|
914
|
+
if item_type == "message":
|
|
915
|
+
role = payload.get("role")
|
|
916
|
+
text = extract_text(payload.get("content"))
|
|
917
|
+
if role == "assistant" and text:
|
|
918
|
+
s = ensure_step(ts)
|
|
919
|
+
s.assistant_text = append_text(s.assistant_text, text)
|
|
920
|
+
elif role == "user" and text and not is_codex_wrapper_text(text) and not t.user_text_fallback:
|
|
921
|
+
t.user_text_fallback = text
|
|
922
|
+
elif item_type == "reasoning":
|
|
923
|
+
text = extract_text(payload.get("content") or payload.get("summary"))
|
|
924
|
+
if text:
|
|
925
|
+
s = ensure_step(ts)
|
|
926
|
+
s.reasoning = append_text(s.reasoning, text)
|
|
927
|
+
elif item_type in ("function_call", "custom_tool_call"):
|
|
928
|
+
call_id = first_string(str(payload.get("call_id") or ""), str(payload.get("id") or "")) or ""
|
|
929
|
+
call = CodexToolCall(
|
|
930
|
+
id=call_id,
|
|
931
|
+
name=first_string(str(payload.get("name") or "")) or "tool",
|
|
932
|
+
input=parse_tool_input(payload.get("arguments") if item_type == "function_call" else payload.get("input")),
|
|
933
|
+
start_time=ts,
|
|
934
|
+
)
|
|
935
|
+
ensure_step(ts).tool_calls.append(call)
|
|
936
|
+
if call_id:
|
|
937
|
+
tool_calls_by_id[call_id] = call
|
|
938
|
+
elif item_type in ("function_call_output", "custom_tool_call_output"):
|
|
939
|
+
call_id = str(payload.get("call_id") or "")
|
|
940
|
+
call = tool_calls_by_id.get(call_id)
|
|
941
|
+
if call:
|
|
942
|
+
call.output = payload.get("output")
|
|
943
|
+
call.end_time = ts
|
|
944
|
+
continue
|
|
945
|
+
|
|
946
|
+
if row_type != "event_msg":
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
event_type = payload.get("type")
|
|
950
|
+
if event_type == "task_started":
|
|
951
|
+
if turn is not None:
|
|
952
|
+
finish_turn(ts, completed=False, aborted=False)
|
|
953
|
+
turn = CodexTurn(
|
|
954
|
+
turn_id=first_string(str(payload.get("turn_id") or "")),
|
|
955
|
+
start_time=ts,
|
|
956
|
+
end_time=ts,
|
|
957
|
+
)
|
|
958
|
+
step = None
|
|
959
|
+
tool_calls_by_id = {}
|
|
960
|
+
continue
|
|
961
|
+
|
|
962
|
+
t = ensure_turn(ts)
|
|
963
|
+
if event_type == "user_message" and isinstance(payload.get("message"), str):
|
|
964
|
+
if not t.user_text:
|
|
965
|
+
t.user_text = payload["message"]
|
|
966
|
+
elif event_type == "agent_message" and isinstance(payload.get("message"), str):
|
|
967
|
+
t.final_output = payload["message"]
|
|
968
|
+
elif event_type == "token_count":
|
|
969
|
+
info = payload.get("info")
|
|
970
|
+
if isinstance(info, dict):
|
|
971
|
+
total = info.get("total_token_usage")
|
|
972
|
+
if isinstance(total, dict):
|
|
973
|
+
t.total_usage = total
|
|
974
|
+
last = info.get("last_token_usage")
|
|
975
|
+
close_step(ts, last if isinstance(last, dict) else None)
|
|
976
|
+
elif event_type == "task_complete":
|
|
977
|
+
finish_turn(ts, completed=True, aborted=False)
|
|
978
|
+
elif event_type == "turn_aborted":
|
|
979
|
+
finish_turn(ts, completed=True, aborted=True)
|
|
980
|
+
else:
|
|
981
|
+
if event_type == "collab_agent_spawn_end" and isinstance(payload.get("new_thread_id"), str):
|
|
982
|
+
t.subagent_thread_ids.append(payload["new_thread_id"])
|
|
983
|
+
call_id = payload.get("call_id")
|
|
984
|
+
if isinstance(call_id, str) and isinstance(event_type, str) and event_type.endswith("_end"):
|
|
985
|
+
call = tool_calls_by_id.get(call_id)
|
|
986
|
+
if call:
|
|
987
|
+
call.end_time = ts
|
|
988
|
+
if isinstance(payload.get("status"), str):
|
|
989
|
+
call.status = payload["status"]
|
|
990
|
+
if payload.get("status") in ("failed", "declined"):
|
|
991
|
+
call.error = first_string(
|
|
992
|
+
str(payload.get("stderr") or ""),
|
|
993
|
+
str(payload.get("error") or ""),
|
|
994
|
+
str(payload.get("message") or ""),
|
|
995
|
+
)
|
|
996
|
+
if call.output is None:
|
|
997
|
+
call.output = {
|
|
998
|
+
"stdout": payload.get("stdout"),
|
|
999
|
+
"stderr": payload.get("stderr"),
|
|
1000
|
+
"success": payload.get("success"),
|
|
1001
|
+
"status": payload.get("status"),
|
|
1002
|
+
"aggregated_output": payload.get("aggregated_output"),
|
|
1003
|
+
}
|
|
1004
|
+
elif isinstance(call.output, str) and payload.get("stdout") is not None:
|
|
1005
|
+
call.output = {
|
|
1006
|
+
"stdout": payload.get("stdout"),
|
|
1007
|
+
"stderr": payload.get("stderr"),
|
|
1008
|
+
"success": payload.get("success"),
|
|
1009
|
+
"status": payload.get("status"),
|
|
1010
|
+
"aggregated_output": payload.get("aggregated_output"),
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if turn is not None:
|
|
1014
|
+
finish_turn(last_ts, completed=False, aborted=False)
|
|
1015
|
+
|
|
1016
|
+
return CodexParsedSession(session_meta=session_meta, turns=turns)
|
|
1017
|
+
|
|
1018
|
+
|
|
751
1019
|
def usage_details_from_codex(usage: Dict[str, Any]) -> Dict[str, int]:
|
|
752
1020
|
raw = usage.get("last_token_usage")
|
|
753
1021
|
if not isinstance(raw, dict):
|
|
@@ -825,6 +1093,77 @@ def collect_turn_material(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
825
1093
|
}
|
|
826
1094
|
|
|
827
1095
|
|
|
1096
|
+
def codex_turn_usage_details(turn: CodexTurn) -> Dict[str, int]:
|
|
1097
|
+
for step in reversed(turn.steps):
|
|
1098
|
+
details = usage_details_from_codex({"last_token_usage": step.usage})
|
|
1099
|
+
if details:
|
|
1100
|
+
return details
|
|
1101
|
+
return {}
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def codex_turn_material(turn: CodexTurn) -> Dict[str, Any]:
|
|
1105
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
1106
|
+
tool_results: List[Dict[str, Any]] = []
|
|
1107
|
+
skill_sources: List[Any] = [turn.invocation_params]
|
|
1108
|
+
for step in turn.steps:
|
|
1109
|
+
skill_sources.append(step.reasoning)
|
|
1110
|
+
for call in step.tool_calls:
|
|
1111
|
+
tool_calls.append({"id": call.id, "name": call.name, "input": call.input})
|
|
1112
|
+
tool_results.append({
|
|
1113
|
+
"id": call.id,
|
|
1114
|
+
"name": call.name,
|
|
1115
|
+
"output": call.output,
|
|
1116
|
+
"status": call.status,
|
|
1117
|
+
"error": call.error,
|
|
1118
|
+
"start_time": call.start_time,
|
|
1119
|
+
"end_time": call.end_time,
|
|
1120
|
+
})
|
|
1121
|
+
skill_sources.append({"name": call.name, "input": call.input, "output": call.output})
|
|
1122
|
+
return {
|
|
1123
|
+
"user_text": turn.user_text,
|
|
1124
|
+
"assistant_text": turn.final_output or "\n\n".join(step.assistant_text for step in turn.steps if step.assistant_text),
|
|
1125
|
+
"tool_calls": tool_calls,
|
|
1126
|
+
"tool_results": tool_results,
|
|
1127
|
+
"skill_detection_sources": skill_sources,
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def codex_turn_upload_key(session_path: Path, turn: CodexTurn, index: int) -> Tuple[str, str]:
|
|
1132
|
+
if turn.turn_id:
|
|
1133
|
+
return "turn_id", str(turn.turn_id)
|
|
1134
|
+
seed = {
|
|
1135
|
+
"session_path": str(session_path),
|
|
1136
|
+
"index": index,
|
|
1137
|
+
"start_time": turn.start_time,
|
|
1138
|
+
"end_time": turn.end_time,
|
|
1139
|
+
"user_text": turn.user_text,
|
|
1140
|
+
"final_output": turn.final_output,
|
|
1141
|
+
"aborted": turn.aborted,
|
|
1142
|
+
}
|
|
1143
|
+
digest = hashlib.sha256(json.dumps(seed, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
1144
|
+
return "fallback", digest
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def should_upload_codex_turn(ss: SessionState, session_path: Path, turn: CodexTurn, index: int) -> bool:
|
|
1148
|
+
if not (turn.completed or turn.aborted):
|
|
1149
|
+
return False
|
|
1150
|
+
material = codex_turn_material(turn)
|
|
1151
|
+
if not material.get("assistant_text") and not material.get("tool_calls") and not material.get("tool_results"):
|
|
1152
|
+
return False
|
|
1153
|
+
kind, value = codex_turn_upload_key(session_path, turn, index)
|
|
1154
|
+
if kind == "turn_id":
|
|
1155
|
+
return value not in ss.uploaded_turn_ids
|
|
1156
|
+
return value not in ss.uploaded_turn_fallback_keys
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def mark_codex_turn_uploaded(ss: SessionState, session_path: Path, turn: CodexTurn, index: int) -> None:
|
|
1160
|
+
kind, value = codex_turn_upload_key(session_path, turn, index)
|
|
1161
|
+
if kind == "turn_id":
|
|
1162
|
+
ss.uploaded_turn_ids.add(value)
|
|
1163
|
+
else:
|
|
1164
|
+
ss.uploaded_turn_fallback_keys.add(value)
|
|
1165
|
+
|
|
1166
|
+
|
|
828
1167
|
def emit_codex_turn(
|
|
829
1168
|
langfuse: Langfuse,
|
|
830
1169
|
session_id: str,
|
|
@@ -832,13 +1171,13 @@ def emit_codex_turn(
|
|
|
832
1171
|
turn_num: int,
|
|
833
1172
|
session_path: Path,
|
|
834
1173
|
meta: Dict[str, Any],
|
|
835
|
-
|
|
836
|
-
usage: Dict[str, Any],
|
|
1174
|
+
turn: CodexTurn,
|
|
837
1175
|
) -> None:
|
|
1176
|
+
material = codex_turn_material(turn)
|
|
838
1177
|
user_text, user_meta = truncate(material.get("user_text") or "")
|
|
839
1178
|
assistant_text, assistant_meta = truncate(material.get("assistant_text") or "")
|
|
840
|
-
usage_details =
|
|
841
|
-
model = first_string(meta.get("model"), meta.get("model_provider")) or "codex"
|
|
1179
|
+
usage_details = codex_turn_usage_details(turn)
|
|
1180
|
+
model = first_string(turn.model, meta.get("model"), meta.get("model_provider")) or "codex"
|
|
842
1181
|
tool_calls = material.get("tool_calls") or []
|
|
843
1182
|
tool_results = material.get("tool_results") or []
|
|
844
1183
|
skill_usages = detect_turn_skill_usages(material, discover_known_skills())
|
|
@@ -863,11 +1202,11 @@ def emit_codex_turn(
|
|
|
863
1202
|
with propagate_attributes(
|
|
864
1203
|
user_id=user_id,
|
|
865
1204
|
session_id=session_id,
|
|
866
|
-
trace_name=AGENT_TURN_NAME,
|
|
867
|
-
tags=[AGENT_NAME],
|
|
868
|
-
):
|
|
869
|
-
with langfuse.start_as_current_observation(
|
|
870
|
-
name=AGENT_TURN_NAME,
|
|
1205
|
+
trace_name=AGENT_TURN_NAME,
|
|
1206
|
+
tags=[AGENT_NAME],
|
|
1207
|
+
):
|
|
1208
|
+
with langfuse.start_as_current_observation(
|
|
1209
|
+
name=AGENT_TURN_NAME,
|
|
871
1210
|
input={"role": "user", "content": user_text},
|
|
872
1211
|
output={"role": "assistant", "content": assistant_text},
|
|
873
1212
|
metadata={
|
|
@@ -881,8 +1220,13 @@ def emit_codex_turn(
|
|
|
881
1220
|
"originator": meta.get("originator"),
|
|
882
1221
|
"cli_version": meta.get("cli_version"),
|
|
883
1222
|
"user_text": user_meta,
|
|
884
|
-
"usage":
|
|
1223
|
+
"usage": {"last_token_usage": usage_details},
|
|
885
1224
|
"skills": skill_summary,
|
|
1225
|
+
"codex_turn_id": turn.turn_id or "",
|
|
1226
|
+
"completed": turn.completed,
|
|
1227
|
+
"aborted": turn.aborted,
|
|
1228
|
+
"subagent_thread_ids": turn.subagent_thread_ids,
|
|
1229
|
+
"step_count": len(turn.steps),
|
|
886
1230
|
},
|
|
887
1231
|
) as trace_span:
|
|
888
1232
|
with langfuse.start_as_current_observation(
|
|
@@ -940,6 +1284,10 @@ def emit_codex_turn(
|
|
|
940
1284
|
"tool_name": result.get("name"),
|
|
941
1285
|
"turn_number": turn_num,
|
|
942
1286
|
"output_meta": output_meta,
|
|
1287
|
+
"status": result.get("status"),
|
|
1288
|
+
"error": result.get("error"),
|
|
1289
|
+
"start_time": result.get("start_time"),
|
|
1290
|
+
"end_time": result.get("end_time"),
|
|
943
1291
|
"metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
944
1292
|
},
|
|
945
1293
|
) as tool_obs:
|
|
@@ -1000,28 +1348,46 @@ def main() -> int:
|
|
|
1000
1348
|
state = read_json_if_exists(STATE_FILE)
|
|
1001
1349
|
key = session_key(session_path)
|
|
1002
1350
|
ss = load_session_state(state, key)
|
|
1003
|
-
rows
|
|
1351
|
+
rows = read_all_jsonl(session_path)
|
|
1004
1352
|
if not rows:
|
|
1353
|
+
try:
|
|
1354
|
+
ss.offset = session_path.stat().st_size
|
|
1355
|
+
ss.buffer = ""
|
|
1356
|
+
except Exception:
|
|
1357
|
+
pass
|
|
1005
1358
|
save_session_state(state, key, ss)
|
|
1006
1359
|
write_json_atomic(STATE_FILE, state)
|
|
1007
1360
|
return 0
|
|
1008
1361
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1362
|
+
try:
|
|
1363
|
+
ss.offset = session_path.stat().st_size
|
|
1364
|
+
ss.buffer = ""
|
|
1365
|
+
except Exception:
|
|
1366
|
+
pass
|
|
1367
|
+
|
|
1368
|
+
parsed = parse_codex_session(rows)
|
|
1369
|
+
if not parsed.turns:
|
|
1011
1370
|
save_session_state(state, key, ss)
|
|
1012
1371
|
write_json_atomic(STATE_FILE, state)
|
|
1013
1372
|
return 0
|
|
1014
1373
|
|
|
1015
1374
|
meta = get_session_meta(rows, session_path)
|
|
1375
|
+
meta.update({k: v for k, v in parsed.session_meta.items() if v not in (None, "")})
|
|
1376
|
+
meta.setdefault("session_path", str(session_path))
|
|
1016
1377
|
session_id = first_string(str(meta.get("id")) if meta.get("id") else "", session_path.stem) or session_path.stem
|
|
1017
|
-
|
|
1018
|
-
turn_num = ss.turn_count + 1
|
|
1378
|
+
uploaded = 0
|
|
1019
1379
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1380
|
+
for index, turn in enumerate(parsed.turns, start=1):
|
|
1381
|
+
if not should_upload_codex_turn(ss, session_path, turn, index):
|
|
1382
|
+
continue
|
|
1383
|
+
turn_num = ss.turn_count + 1
|
|
1384
|
+
try:
|
|
1385
|
+
emit_codex_turn(langfuse, session_id, user_id, turn_num, session_path, meta, turn)
|
|
1386
|
+
mark_codex_turn_uploaded(ss, session_path, turn, index)
|
|
1387
|
+
ss.turn_count = turn_num
|
|
1388
|
+
uploaded += 1
|
|
1389
|
+
except Exception as e:
|
|
1390
|
+
debug(f"emit_codex_turn failed: {e}")
|
|
1025
1391
|
|
|
1026
1392
|
save_session_state(state, key, ss)
|
|
1027
1393
|
write_json_atomic(STATE_FILE, state)
|
|
@@ -1030,7 +1396,7 @@ def main() -> int:
|
|
|
1030
1396
|
langfuse.flush()
|
|
1031
1397
|
except Exception:
|
|
1032
1398
|
pass
|
|
1033
|
-
log("INFO", f"Processed Codex turn {ss.turn_count} for {session_path}")
|
|
1399
|
+
log("INFO", f"Processed {uploaded} Codex turn(s), total {ss.turn_count} for {session_path}")
|
|
1034
1400
|
return 0
|
|
1035
1401
|
except Exception as e:
|
|
1036
1402
|
debug(f"unexpected failure: {e}")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-langfuse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"scripts/log-filter-utils.mjs",
|
|
28
28
|
"scripts/metrics-utils.mjs",
|
|
29
29
|
"scripts/runtime-state-utils.mjs",
|
|
30
|
-
"scripts/update-langfuse-runtime.mjs",
|
|
31
|
-
"scripts/update-utils.mjs",
|
|
32
|
-
"scripts/verify-update-runtime.mjs",
|
|
33
|
-
"scripts/verify-update-utils.mjs",
|
|
34
|
-
"langfuse_hook.py",
|
|
30
|
+
"scripts/update-langfuse-runtime.mjs",
|
|
31
|
+
"scripts/update-utils.mjs",
|
|
32
|
+
"scripts/verify-update-runtime.mjs",
|
|
33
|
+
"scripts/verify-update-utils.mjs",
|
|
34
|
+
"langfuse_hook.py",
|
|
35
35
|
"codex_langfuse_notify.py",
|
|
36
36
|
"README.md",
|
|
37
37
|
"SELF_VERIFY.md",
|