llm-interceptor 2.0.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.
- cci/__init__.py +11 -0
- cci/app.py +319 -0
- cci/cli.py +727 -0
- cci/config.py +225 -0
- cci/filters.py +171 -0
- cci/logger.py +166 -0
- cci/merger.py +673 -0
- cci/models.py +126 -0
- cci/proxy.py +372 -0
- cci/server.py +244 -0
- cci/splitter.py +151 -0
- cci/storage.py +190 -0
- cci/watch.py +454 -0
- llm_interceptor/__init__.py +30 -0
- llm_interceptor/cli.py +21 -0
- llm_interceptor-2.0.0.dist-info/METADATA +336 -0
- llm_interceptor-2.0.0.dist-info/RECORD +19 -0
- llm_interceptor-2.0.0.dist-info/WHEEL +4 -0
- llm_interceptor-2.0.0.dist-info/entry_points.txt +3 -0
cci/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Interceptor (LLI)
|
|
3
|
+
|
|
4
|
+
Intercept and analyze LLM traffic from AI coding tools.
|
|
5
|
+
|
|
6
|
+
Note: The import package name `cci` is kept for backward compatibility.
|
|
7
|
+
New code should prefer `llm_interceptor`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "1.4.0"
|
|
11
|
+
__author__ = "LLM Interceptor Team"
|
cci/app.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import streamlit as st
|
|
8
|
+
|
|
9
|
+
# ================= 1. Config & Utils =================
|
|
10
|
+
|
|
11
|
+
# Set page config first
|
|
12
|
+
st.set_page_config(layout="wide", page_title="LLM Interceptor", page_icon="🔍")
|
|
13
|
+
|
|
14
|
+
# Determine BASE_DIR relative to the project root
|
|
15
|
+
# Assuming this script is in src/cci/app.py and traces is in project root
|
|
16
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
17
|
+
BASE_DIR = PROJECT_ROOT / "traces"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_file_info(filename: str) -> tuple[int | None, str | None, str | None]:
|
|
21
|
+
"""
|
|
22
|
+
Extract metadata from filename: 001_request_2025-12-03...json
|
|
23
|
+
Returns: (seq_id, type, timestamp)
|
|
24
|
+
"""
|
|
25
|
+
# Pattern: ID_TYPE_TIMESTAMP.json
|
|
26
|
+
# Example: 001_request_2025-12-03_15-20-05.json
|
|
27
|
+
match = re.match(r"(\d+)_([a-z]+)_(.+)\.json", filename)
|
|
28
|
+
if match:
|
|
29
|
+
return int(match.group(1)), match.group(2), match.group(3)
|
|
30
|
+
return None, None, None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_session_turns(session_path: Path) -> list[dict[str, Any]]:
|
|
34
|
+
"""
|
|
35
|
+
Core logic: Load and pair Request/Response, calculate context features.
|
|
36
|
+
Looks for 'split_output' subdirectory first, otherwise scans the session dir.
|
|
37
|
+
"""
|
|
38
|
+
# Check for split_output subdirectory
|
|
39
|
+
split_output_dir = session_path / "split_output"
|
|
40
|
+
if split_output_dir.exists() and split_output_dir.is_dir():
|
|
41
|
+
target_dir = split_output_dir
|
|
42
|
+
else:
|
|
43
|
+
target_dir = session_path
|
|
44
|
+
|
|
45
|
+
if not target_dir.exists():
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
files = sorted(os.listdir(target_dir))
|
|
49
|
+
turns_map: dict[int, dict[str, Any]] = {}
|
|
50
|
+
|
|
51
|
+
for f in files:
|
|
52
|
+
if not f.endswith(".json"):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
seq_id, f_type, timestamp = parse_file_info(f)
|
|
56
|
+
if seq_id is None:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
if seq_id not in turns_map:
|
|
60
|
+
turns_map[seq_id] = {
|
|
61
|
+
"seq_id": seq_id,
|
|
62
|
+
"ts": timestamp,
|
|
63
|
+
"req": None,
|
|
64
|
+
"res": None,
|
|
65
|
+
"req_file": None,
|
|
66
|
+
"res_file": None,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Read content
|
|
70
|
+
file_path = target_dir / f
|
|
71
|
+
try:
|
|
72
|
+
with open(file_path, encoding="utf-8") as fp:
|
|
73
|
+
content = json.load(fp)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
content = {"error": f"JSON Decode Fail: {str(e)}"}
|
|
76
|
+
|
|
77
|
+
if f_type == "request":
|
|
78
|
+
turns_map[seq_id]["req"] = content
|
|
79
|
+
turns_map[seq_id]["req_file"] = f
|
|
80
|
+
elif f_type == "response":
|
|
81
|
+
turns_map[seq_id]["res"] = content
|
|
82
|
+
turns_map[seq_id]["res_file"] = f
|
|
83
|
+
|
|
84
|
+
# Convert to list and sort
|
|
85
|
+
sorted_turns = sorted(turns_map.values(), key=lambda x: x["seq_id"])
|
|
86
|
+
|
|
87
|
+
# --- Context Analysis Logic ---
|
|
88
|
+
last_ctx_len = 0
|
|
89
|
+
last_sys_prompt = ""
|
|
90
|
+
|
|
91
|
+
for i, turn in enumerate(sorted_turns):
|
|
92
|
+
req = turn["req"] or {}
|
|
93
|
+
msgs = req.get("messages", [])
|
|
94
|
+
|
|
95
|
+
# 1. Extract current features
|
|
96
|
+
current_ctx_len = len(msgs)
|
|
97
|
+
|
|
98
|
+
# Extract System Prompt Fingerprint (first 50 chars)
|
|
99
|
+
current_sys_prompt = "No System"
|
|
100
|
+
if msgs and msgs[0].get("role") == "system":
|
|
101
|
+
content = msgs[0].get("content", "")
|
|
102
|
+
if isinstance(content, str):
|
|
103
|
+
current_sys_prompt = content[:50]
|
|
104
|
+
elif isinstance(content, list):
|
|
105
|
+
# Handle list content for system prompt if applicable
|
|
106
|
+
current_sys_prompt = "[Complex System Prompt]"
|
|
107
|
+
else:
|
|
108
|
+
current_sys_prompt = str(content)[:50]
|
|
109
|
+
|
|
110
|
+
# 2. Detect Context Switch (Heuristics)
|
|
111
|
+
is_switch = False
|
|
112
|
+
switch_reason = ""
|
|
113
|
+
|
|
114
|
+
if i > 0: # Skip first
|
|
115
|
+
if current_sys_prompt != last_sys_prompt:
|
|
116
|
+
is_switch = True
|
|
117
|
+
switch_reason = "System Prompt Changed"
|
|
118
|
+
elif current_ctx_len < last_ctx_len:
|
|
119
|
+
is_switch = True
|
|
120
|
+
switch_reason = "Context Reset"
|
|
121
|
+
|
|
122
|
+
turn["is_switch"] = is_switch
|
|
123
|
+
turn["switch_reason"] = switch_reason
|
|
124
|
+
turn["ctx_len"] = current_ctx_len
|
|
125
|
+
turn["sys_preview"] = current_sys_prompt
|
|
126
|
+
|
|
127
|
+
# Extract last user message for display
|
|
128
|
+
last_user_msg = "No User Input"
|
|
129
|
+
for m in reversed(msgs):
|
|
130
|
+
if m.get("role") == "user":
|
|
131
|
+
content = m.get("content")
|
|
132
|
+
if isinstance(content, list):
|
|
133
|
+
# Try to extract text from list if possible
|
|
134
|
+
text_parts = [p.get("text", "") for p in content if p.get("type") == "text"]
|
|
135
|
+
if text_parts:
|
|
136
|
+
last_user_msg = " ".join(text_parts)
|
|
137
|
+
else:
|
|
138
|
+
last_user_msg = "[Complex/Image Input]"
|
|
139
|
+
else:
|
|
140
|
+
last_user_msg = content
|
|
141
|
+
break
|
|
142
|
+
turn["last_user_msg"] = last_user_msg
|
|
143
|
+
|
|
144
|
+
# Update state
|
|
145
|
+
last_ctx_len = current_ctx_len
|
|
146
|
+
last_sys_prompt = current_sys_prompt
|
|
147
|
+
|
|
148
|
+
return sorted_turns
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ================= 2. UI Rendering =================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
# Sidebar Styling
|
|
156
|
+
with st.sidebar:
|
|
157
|
+
st.title("🔍 LLM Interceptor")
|
|
158
|
+
st.markdown("---")
|
|
159
|
+
|
|
160
|
+
if not BASE_DIR.exists():
|
|
161
|
+
st.error(f"Traces directory not found: {BASE_DIR}")
|
|
162
|
+
st.info("Please ensure you are running this from the project root.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Scan for session directories (directories containing 'split_output' or .json files)
|
|
166
|
+
sessions = []
|
|
167
|
+
for d in sorted(os.listdir(BASE_DIR)):
|
|
168
|
+
path = BASE_DIR / d
|
|
169
|
+
if path.is_dir():
|
|
170
|
+
sessions.append(d)
|
|
171
|
+
|
|
172
|
+
sessions = sorted(sessions, reverse=True) # Newest first
|
|
173
|
+
|
|
174
|
+
selected_session_name = st.selectbox("Select Session", sessions)
|
|
175
|
+
|
|
176
|
+
st.markdown("### Filters")
|
|
177
|
+
filter_hide_short = st.checkbox(
|
|
178
|
+
"Hide Background Tasks",
|
|
179
|
+
value=False,
|
|
180
|
+
help="Hide tasks with history < 2 messages (often internal calls)",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
search_query = st.text_input("Keyword Search", placeholder="Search content...")
|
|
184
|
+
|
|
185
|
+
if not selected_session_name:
|
|
186
|
+
st.info("👈 Please select a session from the sidebar.")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
session_path = BASE_DIR / selected_session_name
|
|
190
|
+
|
|
191
|
+
# Load Data
|
|
192
|
+
with st.spinner(f"Loading session {selected_session_name}..."):
|
|
193
|
+
turns = load_session_turns(session_path)
|
|
194
|
+
|
|
195
|
+
# Metrics
|
|
196
|
+
st.sidebar.markdown("---")
|
|
197
|
+
st.sidebar.metric("Total API Calls", len(turns))
|
|
198
|
+
|
|
199
|
+
if not turns:
|
|
200
|
+
st.warning("No request/response pairs found in this session.")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Header
|
|
204
|
+
st.title(f"Session: {selected_session_name}")
|
|
205
|
+
st.caption(f"Path: `{session_path}`")
|
|
206
|
+
st.markdown("---")
|
|
207
|
+
|
|
208
|
+
# Main Feed
|
|
209
|
+
displayed_count = 0
|
|
210
|
+
|
|
211
|
+
for turn in turns:
|
|
212
|
+
# --- Filters ---
|
|
213
|
+
if filter_hide_short and turn["ctx_len"] < 2:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if search_query:
|
|
217
|
+
# Simple case-insensitive search in user msg and response
|
|
218
|
+
query = search_query.lower()
|
|
219
|
+
req_match = query in str(turn["last_user_msg"]).lower()
|
|
220
|
+
|
|
221
|
+
res_text = ""
|
|
222
|
+
if turn["res"]:
|
|
223
|
+
try:
|
|
224
|
+
if "choices" in turn["res"]:
|
|
225
|
+
res_text = turn["res"]["choices"][0]["message"]["content"]
|
|
226
|
+
elif "content" in turn["res"]:
|
|
227
|
+
# Handle Anthropic format
|
|
228
|
+
res_text = turn["res"]["content"][0]["text"]
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
res_match = query in str(res_text).lower()
|
|
233
|
+
|
|
234
|
+
if not (req_match or res_match):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
displayed_count += 1
|
|
238
|
+
|
|
239
|
+
# --- Context Switch Separator ---
|
|
240
|
+
if turn.get("is_switch"):
|
|
241
|
+
switch_reason = turn["switch_reason"]
|
|
242
|
+
st.markdown(
|
|
243
|
+
f"""
|
|
244
|
+
<div style="text-align: center; color: #f97316; margin: 30px 0 20px 0;
|
|
245
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;">
|
|
246
|
+
<span style="font-size: 1.2em;">⚡</span>
|
|
247
|
+
<span style="font-weight: 600;">New Context Detected</span>
|
|
248
|
+
<span style="background: #fff7ed; color: #c2410c;
|
|
249
|
+
padding: 2px 8px; border-radius: 12px;
|
|
250
|
+
font-size: 0.8em;">{switch_reason}</span>
|
|
251
|
+
</div>
|
|
252
|
+
<hr style="border: 0; border-top: 2px dashed #fdba74; margin-bottom: 30px;">
|
|
253
|
+
""",
|
|
254
|
+
unsafe_allow_html=True,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# --- Card Component ---
|
|
258
|
+
req = turn["req"]
|
|
259
|
+
res = turn["res"]
|
|
260
|
+
model = req.get("model", "unknown") if req else "unknown"
|
|
261
|
+
|
|
262
|
+
# Card Container with custom styling
|
|
263
|
+
with st.container():
|
|
264
|
+
# Meta Header
|
|
265
|
+
cols = st.columns([4, 1])
|
|
266
|
+
with cols[0]:
|
|
267
|
+
st.caption(
|
|
268
|
+
f"**#{turn['seq_id']}** | 🕒 {turn['ts']} | 🤖 **{model}** | "
|
|
269
|
+
f"📚 History: `{turn['ctx_len']}` | 🎯 Intent: `{turn['sys_preview']}...`"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Content Grid
|
|
273
|
+
c1, c2 = st.columns([1, 1])
|
|
274
|
+
|
|
275
|
+
# LEFT: Request
|
|
276
|
+
with c1:
|
|
277
|
+
st.markdown("**User Input**")
|
|
278
|
+
st.info(turn["last_user_msg"], icon="👤")
|
|
279
|
+
|
|
280
|
+
with st.popover("📄 Full Request JSON", use_container_width=True):
|
|
281
|
+
st.json(req)
|
|
282
|
+
|
|
283
|
+
# RIGHT: Response
|
|
284
|
+
with c2:
|
|
285
|
+
st.markdown("**AI Response**")
|
|
286
|
+
if res:
|
|
287
|
+
# Parse AI Content
|
|
288
|
+
ai_content = "No content"
|
|
289
|
+
try:
|
|
290
|
+
if "choices" in res: # OpenAI format
|
|
291
|
+
ai_content = res["choices"][0]["message"]["content"]
|
|
292
|
+
elif "content" in res: # Anthropic format
|
|
293
|
+
content_block = res["content"]
|
|
294
|
+
if isinstance(content_block, list) and len(content_block) > 0:
|
|
295
|
+
ai_content = content_block[0].get("text", "")
|
|
296
|
+
elif isinstance(content_block, str):
|
|
297
|
+
ai_content = content_block
|
|
298
|
+
except Exception as e:
|
|
299
|
+
ai_content = f"Error parsing content: {e}"
|
|
300
|
+
|
|
301
|
+
# Check for error in response
|
|
302
|
+
if "error" in res:
|
|
303
|
+
st.error(f"API Error: {res['error']}")
|
|
304
|
+
else:
|
|
305
|
+
st.markdown(ai_content)
|
|
306
|
+
|
|
307
|
+
with st.expander("🛠 Raw Response"):
|
|
308
|
+
st.json(res)
|
|
309
|
+
else:
|
|
310
|
+
st.warning("⚠️ No Response Captured", icon="⚠️")
|
|
311
|
+
|
|
312
|
+
st.divider()
|
|
313
|
+
|
|
314
|
+
if displayed_count == 0:
|
|
315
|
+
st.info("No messages match current filters.")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|