epi-recorder 2.1.3__py3-none-any.whl → 2.3.0__py3-none-any.whl
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.
- epi_analyzer/__init__.py +9 -0
- epi_analyzer/detector.py +337 -0
- epi_cli/__init__.py +4 -0
- epi_cli/__main__.py +4 -0
- epi_cli/chat.py +21 -3
- epi_cli/debug.py +107 -0
- epi_cli/keys.py +4 -0
- epi_cli/ls.py +5 -1
- epi_cli/main.py +8 -0
- epi_cli/record.py +4 -0
- epi_cli/run.py +12 -4
- epi_cli/verify.py +4 -0
- epi_cli/view.py +4 -0
- epi_core/__init__.py +5 -1
- epi_core/container.py +68 -55
- epi_core/redactor.py +4 -0
- epi_core/schemas.py +6 -2
- epi_core/serialize.py +4 -0
- epi_core/storage.py +186 -0
- epi_core/trust.py +4 -0
- epi_recorder/__init__.py +13 -1
- epi_recorder/api.py +211 -5
- epi_recorder/async_api.py +151 -0
- epi_recorder/bootstrap.py +4 -0
- epi_recorder/environment.py +4 -0
- epi_recorder/patcher.py +79 -19
- epi_recorder/test_import.py +2 -0
- epi_recorder/test_script.py +2 -0
- epi_recorder/wrappers/__init__.py +16 -0
- epi_recorder/wrappers/base.py +79 -0
- epi_recorder/wrappers/openai.py +178 -0
- epi_recorder-2.3.0.dist-info/METADATA +269 -0
- epi_recorder-2.3.0.dist-info/RECORD +41 -0
- {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/WHEEL +1 -1
- epi_recorder-2.3.0.dist-info/licenses/LICENSE +21 -0
- {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/top_level.txt +1 -0
- epi_viewer_static/app.js +113 -7
- epi_viewer_static/crypto.js +3 -0
- epi_viewer_static/index.html +4 -2
- epi_viewer_static/viewer_lite.css +3 -1
- epi_postinstall.py +0 -197
- epi_recorder-2.1.3.dist-info/METADATA +0 -577
- epi_recorder-2.1.3.dist-info/RECORD +0 -34
- epi_recorder-2.1.3.dist-info/licenses/LICENSE +0 -201
- {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/entry_points.txt +0 -0
epi_core/container.py
CHANGED
|
@@ -11,6 +11,7 @@ Implements the EPI file format specification:
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
13
|
import tempfile
|
|
14
|
+
import threading
|
|
14
15
|
import zipfile
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Optional
|
|
@@ -21,6 +22,9 @@ from epi_core.schemas import ManifestModel
|
|
|
21
22
|
# EPI mimetype constant (vendor-specific MIME type per RFC 6838)
|
|
22
23
|
EPI_MIMETYPE = "application/vnd.epi+zip"
|
|
23
24
|
|
|
25
|
+
# Thread-safe lock for ZIP packing operations (prevents concurrent corruption)
|
|
26
|
+
_zip_pack_lock = threading.Lock()
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class EPIContainer:
|
|
26
30
|
"""
|
|
@@ -157,6 +161,8 @@ class EPIContainer:
|
|
|
157
161
|
"""
|
|
158
162
|
Create a .epi file from a source directory.
|
|
159
163
|
|
|
164
|
+
Thread-safe: Uses a module-level lock to prevent concurrent ZIP corruption.
|
|
165
|
+
|
|
160
166
|
The packing process:
|
|
161
167
|
1. Write mimetype first (uncompressed) per ZIP spec
|
|
162
168
|
2. Hash all files in source_dir
|
|
@@ -173,64 +179,67 @@ class EPIContainer:
|
|
|
173
179
|
FileNotFoundError: If source_dir doesn't exist
|
|
174
180
|
ValueError: If source_dir is not a directory
|
|
175
181
|
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# Create embedded viewer with data injection
|
|
205
|
-
viewer_html = EPIContainer._create_embedded_viewer(source_dir, manifest)
|
|
206
|
-
|
|
207
|
-
# Create ZIP file
|
|
208
|
-
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
209
|
-
# 1. Write mimetype FIRST and UNCOMPRESSED (per EPI spec)
|
|
210
|
-
zf.writestr(
|
|
211
|
-
"mimetype",
|
|
212
|
-
EPI_MIMETYPE,
|
|
213
|
-
compress_type=zipfile.ZIP_STORED # No compression
|
|
214
|
-
)
|
|
182
|
+
# CRITICAL: Acquire lock to prevent concurrent ZIP corruption
|
|
183
|
+
# Multiple threads writing to ZIP simultaneously causes file header mismatches
|
|
184
|
+
with _zip_pack_lock:
|
|
185
|
+
if not source_dir.exists():
|
|
186
|
+
raise FileNotFoundError(f"Source directory not found: {source_dir}")
|
|
187
|
+
|
|
188
|
+
if not source_dir.is_dir():
|
|
189
|
+
raise ValueError(f"Source must be a directory: {source_dir}")
|
|
190
|
+
|
|
191
|
+
# Ensure output directory exists
|
|
192
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
# Collect all files and compute hashes
|
|
195
|
+
file_manifest = {}
|
|
196
|
+
files_to_pack = []
|
|
197
|
+
|
|
198
|
+
for file_path in source_dir.rglob("*"):
|
|
199
|
+
if file_path.is_file():
|
|
200
|
+
# Get relative path for archive
|
|
201
|
+
rel_path = file_path.relative_to(source_dir)
|
|
202
|
+
arc_name = str(rel_path).replace("\\", "/") # Use forward slashes in ZIP
|
|
203
|
+
|
|
204
|
+
# Compute hash
|
|
205
|
+
file_hash = EPIContainer._compute_file_hash(file_path)
|
|
206
|
+
file_manifest[arc_name] = file_hash
|
|
207
|
+
|
|
208
|
+
files_to_pack.append((file_path, arc_name))
|
|
215
209
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
zf.write(file_path, arc_name, compress_type=zipfile.ZIP_DEFLATED)
|
|
210
|
+
# Update manifest with file hashes
|
|
211
|
+
manifest.file_manifest = file_manifest
|
|
219
212
|
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
"viewer.html",
|
|
223
|
-
viewer_html,
|
|
224
|
-
compress_type=zipfile.ZIP_DEFLATED
|
|
225
|
-
)
|
|
213
|
+
# Create embedded viewer with data injection
|
|
214
|
+
viewer_html = EPIContainer._create_embedded_viewer(source_dir, manifest)
|
|
226
215
|
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
216
|
+
# Create ZIP file
|
|
217
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
218
|
+
# 1. Write mimetype FIRST and UNCOMPRESSED (per EPI spec)
|
|
219
|
+
zf.writestr(
|
|
220
|
+
"mimetype",
|
|
221
|
+
EPI_MIMETYPE,
|
|
222
|
+
compress_type=zipfile.ZIP_STORED # No compression
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# 2. Write all other files
|
|
226
|
+
for file_path, arc_name in files_to_pack:
|
|
227
|
+
zf.write(file_path, arc_name, compress_type=zipfile.ZIP_DEFLATED)
|
|
228
|
+
|
|
229
|
+
# 3. Write embedded viewer
|
|
230
|
+
zf.writestr(
|
|
231
|
+
"viewer.html",
|
|
232
|
+
viewer_html,
|
|
233
|
+
compress_type=zipfile.ZIP_DEFLATED
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# 4. Write manifest.json LAST (after all files are hashed)
|
|
237
|
+
manifest_json = manifest.model_dump_json(indent=2)
|
|
238
|
+
zf.writestr(
|
|
239
|
+
"manifest.json",
|
|
240
|
+
manifest_json,
|
|
241
|
+
compress_type=zipfile.ZIP_DEFLATED
|
|
242
|
+
)
|
|
234
243
|
|
|
235
244
|
@staticmethod
|
|
236
245
|
def unpack(epi_path: Path, dest_dir: Optional[Path] = None) -> Path:
|
|
@@ -350,3 +359,7 @@ class EPIContainer:
|
|
|
350
359
|
mismatches[filename] = f"Hash mismatch: expected {expected_hash}, got {actual_hash}"
|
|
351
360
|
|
|
352
361
|
return (len(mismatches) == 0, mismatches)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
|
epi_core/redactor.py
CHANGED
epi_core/schemas.py
CHANGED
|
@@ -18,7 +18,7 @@ class ManifestModel(BaseModel):
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
spec_version: str = Field(
|
|
21
|
-
default="
|
|
21
|
+
default="2.3.0",
|
|
22
22
|
description="EPI specification version"
|
|
23
23
|
)
|
|
24
24
|
|
|
@@ -145,4 +145,8 @@ class StepModel(BaseModel):
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
-
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
epi_core/serialize.py
CHANGED
epi_core/storage.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite-based storage for EPI recordings.
|
|
3
|
+
|
|
4
|
+
Provides atomic, crash-safe storage replacing JSONL files.
|
|
5
|
+
SQLite transactions ensure no data corruption on crashes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Dict, Any, Optional
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from .schemas import StepModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EpiStorage:
|
|
19
|
+
"""
|
|
20
|
+
SQLite-based atomic storage for agent execution.
|
|
21
|
+
Replaces JSONL (which corrupts on crashes).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, session_id: str, output_dir: Path):
|
|
25
|
+
"""
|
|
26
|
+
Initialize SQLite storage.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
session_id: Unique session identifier
|
|
30
|
+
output_dir: Directory for database file
|
|
31
|
+
"""
|
|
32
|
+
self.session_id = session_id
|
|
33
|
+
self.output_dir = Path(output_dir)
|
|
34
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
self.db_path = self.output_dir / f"{session_id}_temp.db"
|
|
37
|
+
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
38
|
+
self._init_tables()
|
|
39
|
+
|
|
40
|
+
def _init_tables(self):
|
|
41
|
+
"""Initialize database schema"""
|
|
42
|
+
self.conn.execute('''
|
|
43
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
step_index INTEGER NOT NULL,
|
|
46
|
+
timestamp TEXT NOT NULL,
|
|
47
|
+
kind TEXT NOT NULL,
|
|
48
|
+
content TEXT NOT NULL,
|
|
49
|
+
created_at REAL NOT NULL
|
|
50
|
+
)
|
|
51
|
+
''')
|
|
52
|
+
|
|
53
|
+
self.conn.execute('''
|
|
54
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
55
|
+
key TEXT PRIMARY KEY,
|
|
56
|
+
value TEXT NOT NULL
|
|
57
|
+
)
|
|
58
|
+
''')
|
|
59
|
+
|
|
60
|
+
self.conn.execute('''
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_steps_index
|
|
62
|
+
ON steps(step_index)
|
|
63
|
+
''')
|
|
64
|
+
|
|
65
|
+
self.conn.commit()
|
|
66
|
+
|
|
67
|
+
def add_step(self, step: StepModel) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Atomic insert of execution step.
|
|
70
|
+
Survives process crashes.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
step: StepModel to persist
|
|
74
|
+
"""
|
|
75
|
+
self.conn.execute(
|
|
76
|
+
'''INSERT INTO steps
|
|
77
|
+
(step_index, timestamp, kind, content, created_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?)''',
|
|
79
|
+
(
|
|
80
|
+
step.index,
|
|
81
|
+
step.timestamp.isoformat(),
|
|
82
|
+
step.kind,
|
|
83
|
+
step.model_dump_json(),
|
|
84
|
+
time.time()
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
self.conn.commit()
|
|
88
|
+
|
|
89
|
+
def get_steps(self) -> List[StepModel]:
|
|
90
|
+
"""
|
|
91
|
+
Retrieve all steps in order.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of StepModel instances
|
|
95
|
+
"""
|
|
96
|
+
cursor = self.conn.execute(
|
|
97
|
+
'SELECT content FROM steps ORDER BY step_index'
|
|
98
|
+
)
|
|
99
|
+
rows = cursor.fetchall()
|
|
100
|
+
|
|
101
|
+
steps = []
|
|
102
|
+
for row in rows:
|
|
103
|
+
step_data = json.loads(row[0])
|
|
104
|
+
steps.append(StepModel(**step_data))
|
|
105
|
+
|
|
106
|
+
return steps
|
|
107
|
+
|
|
108
|
+
def set_metadata(self, key: str, value: str) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Set metadata key-value pair.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
key: Metadata key
|
|
114
|
+
value: Metadata value
|
|
115
|
+
"""
|
|
116
|
+
self.conn.execute(
|
|
117
|
+
'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
|
|
118
|
+
(key, value)
|
|
119
|
+
)
|
|
120
|
+
self.conn.commit()
|
|
121
|
+
|
|
122
|
+
def get_metadata(self, key: str) -> Optional[str]:
|
|
123
|
+
"""
|
|
124
|
+
Get metadata value.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
key: Metadata key
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Metadata value or None
|
|
131
|
+
"""
|
|
132
|
+
cursor = self.conn.execute(
|
|
133
|
+
'SELECT value FROM metadata WHERE key = ?',
|
|
134
|
+
(key,)
|
|
135
|
+
)
|
|
136
|
+
row = cursor.fetchone()
|
|
137
|
+
return row[0] if row else None
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
"""Close database connection."""
|
|
141
|
+
if self.conn:
|
|
142
|
+
self.conn.close()
|
|
143
|
+
|
|
144
|
+
def export_to_jsonl(self, output_path: Path) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Export steps to JSONL file for backwards compatibility.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
output_path: Path to JSONL file
|
|
150
|
+
"""
|
|
151
|
+
steps = self.get_steps()
|
|
152
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
153
|
+
for step in steps:
|
|
154
|
+
f.write(step.model_dump_json() + '\n')
|
|
155
|
+
|
|
156
|
+
def finalize(self) -> Path:
|
|
157
|
+
"""
|
|
158
|
+
Finalize recording and rename to final path.
|
|
159
|
+
This ensures we never have half-written files.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Path to finalized database file
|
|
163
|
+
"""
|
|
164
|
+
# Add finalization metadata
|
|
165
|
+
self.set_metadata('finalized_at', datetime.utcnow().isoformat())
|
|
166
|
+
self.set_metadata('session_id', self.session_id)
|
|
167
|
+
|
|
168
|
+
# Close connection
|
|
169
|
+
self.close()
|
|
170
|
+
|
|
171
|
+
# Atomic rename (SQLite transaction guarantees consistency)
|
|
172
|
+
final_path = self.output_dir / "steps.jsonl"
|
|
173
|
+
|
|
174
|
+
# Export to JSONL for backwards compatibility
|
|
175
|
+
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
176
|
+
self.export_to_jsonl(final_path)
|
|
177
|
+
self.close()
|
|
178
|
+
|
|
179
|
+
# Clean up temp DB
|
|
180
|
+
self.db_path.unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
return final_path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
epi_core/trust.py
CHANGED
epi_recorder/__init__.py
CHANGED
|
@@ -4,7 +4,7 @@ EPI Recorder - Runtime interception and workflow capture.
|
|
|
4
4
|
Python API for recording AI workflows with cryptographic verification.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "2.
|
|
7
|
+
__version__ = "2.3.0"
|
|
8
8
|
|
|
9
9
|
# Export Python API
|
|
10
10
|
from epi_recorder.api import (
|
|
@@ -13,9 +13,21 @@ from epi_recorder.api import (
|
|
|
13
13
|
get_current_session
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
# Export wrapper clients (new in v2.3.0)
|
|
17
|
+
from epi_recorder.wrappers import (
|
|
18
|
+
wrap_openai,
|
|
19
|
+
TracedOpenAI,
|
|
20
|
+
)
|
|
21
|
+
|
|
16
22
|
__all__ = [
|
|
17
23
|
"EpiRecorderSession",
|
|
18
24
|
"record",
|
|
19
25
|
"get_current_session",
|
|
26
|
+
"wrap_openai",
|
|
27
|
+
"TracedOpenAI",
|
|
20
28
|
"__version__"
|
|
21
29
|
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
epi_recorder/api.py
CHANGED
|
@@ -54,6 +54,8 @@ class EpiRecorderSession:
|
|
|
54
54
|
metrics: Optional[Dict[str, Union[float, str]]] = None,
|
|
55
55
|
approved_by: Optional[str] = None,
|
|
56
56
|
metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict with tags parameter
|
|
57
|
+
# Legacy mode (deprecated)
|
|
58
|
+
legacy_patching: bool = False,
|
|
57
59
|
):
|
|
58
60
|
"""
|
|
59
61
|
Initialize EPI recording session.
|
|
@@ -70,6 +72,7 @@ class EpiRecorderSession:
|
|
|
70
72
|
metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
|
|
71
73
|
approved_by: Person or entity who approved this workflow execution
|
|
72
74
|
metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
|
|
75
|
+
legacy_patching: Enable deprecated monkey patching mode (default: False)
|
|
73
76
|
"""
|
|
74
77
|
self.output_path = Path(output_path)
|
|
75
78
|
self.workflow_name = workflow_name or "untitled"
|
|
@@ -85,6 +88,9 @@ class EpiRecorderSession:
|
|
|
85
88
|
self.approved_by = approved_by
|
|
86
89
|
self.metadata_tags = metadata_tags
|
|
87
90
|
|
|
91
|
+
# Legacy mode flag (deprecated)
|
|
92
|
+
self.legacy_patching = legacy_patching
|
|
93
|
+
|
|
88
94
|
# Runtime state
|
|
89
95
|
self.temp_dir: Optional[Path] = None
|
|
90
96
|
self.recording_context: Optional[RecordingContext] = None
|
|
@@ -117,9 +123,17 @@ class EpiRecorderSession:
|
|
|
117
123
|
set_recording_context(self.recording_context)
|
|
118
124
|
_thread_local.active_session = self
|
|
119
125
|
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
# Only patch LLM libraries if legacy mode is enabled (deprecated)
|
|
127
|
+
if self.legacy_patching:
|
|
128
|
+
import warnings
|
|
129
|
+
warnings.warn(
|
|
130
|
+
"legacy_patching is deprecated and will be removed in v3.0.0. "
|
|
131
|
+
"Use epi.log_llm_call() or wrapper clients (wrap_openai) instead.",
|
|
132
|
+
DeprecationWarning,
|
|
133
|
+
stacklevel=2
|
|
134
|
+
)
|
|
135
|
+
from epi_recorder.patcher import patch_all
|
|
136
|
+
patch_all()
|
|
123
137
|
|
|
124
138
|
# Log session start
|
|
125
139
|
self.log_step("session.start", {
|
|
@@ -176,6 +190,11 @@ class EpiRecorderSession:
|
|
|
176
190
|
output_path=self.output_path
|
|
177
191
|
)
|
|
178
192
|
|
|
193
|
+
# CRITICAL: Windows file system flush
|
|
194
|
+
# Allow OS to finalize file before signing
|
|
195
|
+
import time
|
|
196
|
+
time.sleep(0.1)
|
|
197
|
+
|
|
179
198
|
# Sign if requested
|
|
180
199
|
if self.auto_sign:
|
|
181
200
|
self._sign_epi_file()
|
|
@@ -245,6 +264,172 @@ class EpiRecorderSession:
|
|
|
245
264
|
**response_payload
|
|
246
265
|
})
|
|
247
266
|
|
|
267
|
+
def log_llm_call(
|
|
268
|
+
self,
|
|
269
|
+
response: Any,
|
|
270
|
+
messages: Optional[List[Dict[str, str]]] = None,
|
|
271
|
+
provider: str = "auto"
|
|
272
|
+
) -> None:
|
|
273
|
+
"""
|
|
274
|
+
Log a complete LLM call (request + response) from any provider.
|
|
275
|
+
|
|
276
|
+
Auto-detects OpenAI, Anthropic, and Gemini response objects.
|
|
277
|
+
This is the RECOMMENDED way to log LLM calls without monkey patching.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
response: The LLM response object (OpenAI, Anthropic, Gemini, etc.)
|
|
281
|
+
messages: Optional original messages (for request logging)
|
|
282
|
+
provider: Provider name ("auto" to detect, or "openai", "anthropic", etc.)
|
|
283
|
+
|
|
284
|
+
Example:
|
|
285
|
+
with record("my_agent.epi") as epi:
|
|
286
|
+
response = client.chat.completions.create(
|
|
287
|
+
model="gpt-4",
|
|
288
|
+
messages=[{"role": "user", "content": "Hello"}]
|
|
289
|
+
)
|
|
290
|
+
epi.log_llm_call(response, messages=[{"role": "user", "content": "Hello"}])
|
|
291
|
+
"""
|
|
292
|
+
if not self._entered:
|
|
293
|
+
raise RuntimeError("Cannot log LLM call outside of context manager")
|
|
294
|
+
|
|
295
|
+
# Auto-detect provider and extract data
|
|
296
|
+
model = "unknown"
|
|
297
|
+
content = ""
|
|
298
|
+
usage = None
|
|
299
|
+
choices = []
|
|
300
|
+
|
|
301
|
+
# Try OpenAI format
|
|
302
|
+
if hasattr(response, "choices") and hasattr(response, "model"):
|
|
303
|
+
provider = "openai" if provider == "auto" else provider
|
|
304
|
+
model = getattr(response, "model", "unknown")
|
|
305
|
+
|
|
306
|
+
for choice in response.choices:
|
|
307
|
+
msg = choice.message
|
|
308
|
+
choices.append({
|
|
309
|
+
"message": {
|
|
310
|
+
"role": getattr(msg, "role", "assistant"),
|
|
311
|
+
"content": getattr(msg, "content", ""),
|
|
312
|
+
},
|
|
313
|
+
"finish_reason": getattr(choice, "finish_reason", None),
|
|
314
|
+
})
|
|
315
|
+
if not content:
|
|
316
|
+
content = getattr(msg, "content", "")
|
|
317
|
+
|
|
318
|
+
if hasattr(response, "usage") and response.usage:
|
|
319
|
+
usage = {
|
|
320
|
+
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
|
321
|
+
"completion_tokens": getattr(response.usage, "completion_tokens", 0),
|
|
322
|
+
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Try Anthropic format
|
|
326
|
+
elif hasattr(response, "content") and hasattr(response, "model"):
|
|
327
|
+
provider = "anthropic" if provider == "auto" else provider
|
|
328
|
+
model = getattr(response, "model", "unknown")
|
|
329
|
+
|
|
330
|
+
# Anthropic returns content as a list of content blocks
|
|
331
|
+
content_blocks = getattr(response, "content", [])
|
|
332
|
+
if content_blocks and hasattr(content_blocks[0], "text"):
|
|
333
|
+
content = content_blocks[0].text
|
|
334
|
+
choices = [{"message": {"role": "assistant", "content": content}}]
|
|
335
|
+
|
|
336
|
+
if hasattr(response, "usage"):
|
|
337
|
+
usage = {
|
|
338
|
+
"input_tokens": getattr(response.usage, "input_tokens", 0),
|
|
339
|
+
"output_tokens": getattr(response.usage, "output_tokens", 0),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# Try Gemini format
|
|
343
|
+
elif hasattr(response, "text") and hasattr(response, "candidates"):
|
|
344
|
+
provider = "gemini" if provider == "auto" else provider
|
|
345
|
+
model = "gemini"
|
|
346
|
+
content = getattr(response, "text", "")
|
|
347
|
+
choices = [{"message": {"role": "assistant", "content": content}}]
|
|
348
|
+
|
|
349
|
+
# Fallback: try to extract as dict or string
|
|
350
|
+
else:
|
|
351
|
+
provider = provider if provider != "auto" else "unknown"
|
|
352
|
+
if isinstance(response, dict):
|
|
353
|
+
content = str(response.get("content", response))
|
|
354
|
+
else:
|
|
355
|
+
content = str(response)
|
|
356
|
+
choices = [{"message": {"role": "assistant", "content": content}}]
|
|
357
|
+
|
|
358
|
+
# Log request if messages provided
|
|
359
|
+
if messages:
|
|
360
|
+
self.log_step("llm.request", {
|
|
361
|
+
"provider": provider,
|
|
362
|
+
"model": model,
|
|
363
|
+
"messages": messages,
|
|
364
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
# Log response
|
|
368
|
+
response_data = {
|
|
369
|
+
"provider": provider,
|
|
370
|
+
"model": model,
|
|
371
|
+
"choices": choices,
|
|
372
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
373
|
+
}
|
|
374
|
+
if usage:
|
|
375
|
+
response_data["usage"] = usage
|
|
376
|
+
|
|
377
|
+
self.log_step("llm.response", response_data)
|
|
378
|
+
|
|
379
|
+
def log_chat(
|
|
380
|
+
self,
|
|
381
|
+
model: str,
|
|
382
|
+
messages: List[Dict[str, str]],
|
|
383
|
+
response_content: str,
|
|
384
|
+
provider: str = "custom",
|
|
385
|
+
usage: Optional[Dict[str, int]] = None,
|
|
386
|
+
**metadata
|
|
387
|
+
) -> None:
|
|
388
|
+
"""
|
|
389
|
+
Simplified logging for chat completions.
|
|
390
|
+
|
|
391
|
+
Use this when you have the raw data instead of response objects.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
model: Model name (e.g., "gpt-4", "claude-3")
|
|
395
|
+
messages: The messages sent to the model
|
|
396
|
+
response_content: The assistant's response text
|
|
397
|
+
provider: Provider name (default: "custom")
|
|
398
|
+
usage: Optional token usage dict
|
|
399
|
+
**metadata: Additional metadata to include
|
|
400
|
+
|
|
401
|
+
Example:
|
|
402
|
+
epi.log_chat(
|
|
403
|
+
model="gpt-4",
|
|
404
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
405
|
+
response_content="Hi there!",
|
|
406
|
+
tokens=150
|
|
407
|
+
)
|
|
408
|
+
"""
|
|
409
|
+
if not self._entered:
|
|
410
|
+
raise RuntimeError("Cannot log chat outside of context manager")
|
|
411
|
+
|
|
412
|
+
# Log request
|
|
413
|
+
self.log_step("llm.request", {
|
|
414
|
+
"provider": provider,
|
|
415
|
+
"model": model,
|
|
416
|
+
"messages": messages,
|
|
417
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
418
|
+
**metadata
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
# Log response
|
|
422
|
+
response_data = {
|
|
423
|
+
"provider": provider,
|
|
424
|
+
"model": model,
|
|
425
|
+
"choices": [{"message": {"role": "assistant", "content": response_content}}],
|
|
426
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
427
|
+
}
|
|
428
|
+
if usage:
|
|
429
|
+
response_data["usage"] = usage
|
|
430
|
+
|
|
431
|
+
self.log_step("llm.response", response_data)
|
|
432
|
+
|
|
248
433
|
def log_artifact(
|
|
249
434
|
self,
|
|
250
435
|
file_path: Path,
|
|
@@ -355,7 +540,24 @@ class EpiRecorderSession:
|
|
|
355
540
|
encoding="utf-8"
|
|
356
541
|
)
|
|
357
542
|
|
|
358
|
-
#
|
|
543
|
+
# Regenerate viewer.html with signed manifest
|
|
544
|
+
steps = []
|
|
545
|
+
steps_file = tmp_path / "steps.jsonl"
|
|
546
|
+
if steps_file.exists():
|
|
547
|
+
for line in steps_file.read_text(encoding="utf-8").strip().split("\n"):
|
|
548
|
+
if line:
|
|
549
|
+
try:
|
|
550
|
+
steps.append(json.loads(line))
|
|
551
|
+
except json.JSONDecodeError:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
# Regenerate viewer with signed manifest
|
|
555
|
+
from epi_core.container import EPIContainer
|
|
556
|
+
viewer_html = EPIContainer._create_embedded_viewer(tmp_path, signed_manifest)
|
|
557
|
+
viewer_path = tmp_path / "viewer.html"
|
|
558
|
+
viewer_path.write_text(viewer_html, encoding="utf-8")
|
|
559
|
+
|
|
560
|
+
# Repack the ZIP with signed manifest and updated viewer
|
|
359
561
|
# CRITICAL: Write to temp file first to prevent data loss
|
|
360
562
|
temp_output = self.output_path.with_suffix('.epi.tmp')
|
|
361
563
|
|
|
@@ -590,4 +792,8 @@ def get_current_session() -> Optional[EpiRecorderSession]:
|
|
|
590
792
|
Returns:
|
|
591
793
|
EpiRecorderSession or None
|
|
592
794
|
"""
|
|
593
|
-
return getattr(_thread_local, 'active_session', None)
|
|
795
|
+
return getattr(_thread_local, 'active_session', None)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
|