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 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()