llm-interceptor 2.2.0__tar.gz → 2.3.0__tar.gz
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.
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/CHANGELOG.md +16 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/PKG-INFO +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/cci.example.toml +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/lli.example.toml +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/pyproject.toml +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/__init__.py +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/cli.py +103 -41
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/config.py +1 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/logger.py +9 -1
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/proxy.py +36 -19
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/README.md +5 -0
- llm_interceptor-2.3.0/ui/src/components/layout/SessionsSidebar.tsx +311 -0
- llm_interceptor-2.2.0/ui/src/components/layout/SessionsSidebar.tsx +0 -271
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.github/workflows/ci.yml +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.github/workflows/publish.yml +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.gitignore +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.pre-commit-config.yaml +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/README.md +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/build_ui.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/cci-ui-screenshot.png +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/app.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/filters.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/merger.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/models.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/server.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/splitter.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/storage.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/watch.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/llm_interceptor/__init__.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/llm_interceptor/cli.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/__init__.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_basic.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_merger.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_watch_cancel.py +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/.gitignore +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/eslint.config.js +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/index.html +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/package-lock.json +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/package.json +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/postcss.config.js +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/public/cci-icon.png +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/public/vite.svg +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/App.css +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/App.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/assets/react.svg +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/ChatBubble.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/MessageContent.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/ToolCallBlock.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/CopyButton.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/JSONViewer.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/TokenBadge.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/Tooltip.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/EmptyState.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/ExchangeDetailsPane.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/RequestsPane.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/tools/ToolsList.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useAnnotations.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useResizablePanels.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useSessions.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useTheme.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/index.css +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/main.tsx +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/types.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/utils/ui.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/utils.ts +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tailwind.config.js +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.app.json +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.json +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.node.json +0 -0
- {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/vite.config.ts +0 -0
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.3.0] - 2026-01-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Watch session start log** - Print a clear session start marker when recording begins in watch mode
|
|
13
|
+
|
|
14
|
+
## [2.2.1] - 2026-01-06
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Configurable UI host/port** - Allow configuring the UI host and port
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **React hooks usage** - Avoid hooks in sessions map to prevent runtime errors
|
|
23
|
+
|
|
8
24
|
## [2.2.0] - 2026-01-05
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-interceptor
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Intercept and analyze LLM traffic from AI coding tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/chouzz/llm-interceptor
|
|
6
6
|
Project-URL: Documentation, https://github.com/chouzz/llm-interceptor#readme
|
|
@@ -273,31 +273,31 @@ def _show_proxy_help() -> None:
|
|
|
273
273
|
console.print()
|
|
274
274
|
|
|
275
275
|
console.print("[bold]Environment Variables (Shell):[/]")
|
|
276
|
-
console.print(" export HTTP_PROXY=http://127.0.0.1:
|
|
277
|
-
console.print(" export HTTPS_PROXY=http://127.0.0.1:
|
|
276
|
+
console.print(" export HTTP_PROXY=http://127.0.0.1:9090")
|
|
277
|
+
console.print(" export HTTPS_PROXY=http://127.0.0.1:9090")
|
|
278
278
|
console.print()
|
|
279
279
|
|
|
280
280
|
console.print("[bold]Claude Code:[/]")
|
|
281
281
|
console.print(" # Set in your shell before running claude:")
|
|
282
|
-
console.print(" export HTTP_PROXY=http://127.0.0.1:
|
|
283
|
-
console.print(" export HTTPS_PROXY=http://127.0.0.1:
|
|
282
|
+
console.print(" export HTTP_PROXY=http://127.0.0.1:9090")
|
|
283
|
+
console.print(" export HTTPS_PROXY=http://127.0.0.1:9090")
|
|
284
284
|
console.print(" claude")
|
|
285
285
|
console.print()
|
|
286
286
|
|
|
287
287
|
console.print("[bold]Cursor IDE:[/]")
|
|
288
288
|
console.print(" # Add to your shell profile (.bashrc, .zshrc):")
|
|
289
|
-
console.print(" export HTTP_PROXY=http://127.0.0.1:
|
|
290
|
-
console.print(" export HTTPS_PROXY=http://127.0.0.1:
|
|
289
|
+
console.print(" export HTTP_PROXY=http://127.0.0.1:9090")
|
|
290
|
+
console.print(" export HTTPS_PROXY=http://127.0.0.1:9090")
|
|
291
291
|
console.print(" # Then restart Cursor from that terminal")
|
|
292
292
|
console.print()
|
|
293
293
|
|
|
294
294
|
console.print("[bold]curl:[/]")
|
|
295
|
-
console.print(" curl -x http://127.0.0.1:
|
|
295
|
+
console.print(" curl -x http://127.0.0.1:9090 https://api.anthropic.com/v1/messages ...")
|
|
296
296
|
console.print()
|
|
297
297
|
|
|
298
298
|
console.print("[bold]Python requests:[/]")
|
|
299
299
|
console.print(" import requests")
|
|
300
|
-
console.print(' proxies = {"http": "http://127.0.0.1:
|
|
300
|
+
console.print(' proxies = {"http": "http://127.0.0.1:9090", "https": "http://127.0.0.1:9090"}')
|
|
301
301
|
console.print(" requests.post(url, proxies=proxies, verify=False)")
|
|
302
302
|
|
|
303
303
|
|
|
@@ -373,6 +373,7 @@ def stats(file: str) -> None:
|
|
|
373
373
|
"-p",
|
|
374
374
|
type=int,
|
|
375
375
|
default=9090,
|
|
376
|
+
show_default=True,
|
|
376
377
|
help="Proxy server port (default: 9090)",
|
|
377
378
|
)
|
|
378
379
|
@click.option(
|
|
@@ -400,6 +401,19 @@ def stats(file: str) -> None:
|
|
|
400
401
|
default=True,
|
|
401
402
|
help="Launch the web UI (default: True)",
|
|
402
403
|
)
|
|
404
|
+
@click.option(
|
|
405
|
+
"--ui-host",
|
|
406
|
+
default="127.0.0.1",
|
|
407
|
+
show_default=True,
|
|
408
|
+
help="Host interface for the web UI (use 0.0.0.0 to expose on the network)",
|
|
409
|
+
)
|
|
410
|
+
@click.option(
|
|
411
|
+
"--ui-port",
|
|
412
|
+
default=8000,
|
|
413
|
+
show_default=True,
|
|
414
|
+
type=int,
|
|
415
|
+
help="Port for the web UI",
|
|
416
|
+
)
|
|
403
417
|
@click.pass_context
|
|
404
418
|
def watch(
|
|
405
419
|
ctx: click.Context,
|
|
@@ -408,6 +422,8 @@ def watch(
|
|
|
408
422
|
include: tuple[str, ...],
|
|
409
423
|
debug: bool,
|
|
410
424
|
ui: bool,
|
|
425
|
+
ui_host: str,
|
|
426
|
+
ui_port: int,
|
|
411
427
|
) -> None:
|
|
412
428
|
"""
|
|
413
429
|
Start watch mode for continuous session capture.
|
|
@@ -429,7 +445,7 @@ def watch(
|
|
|
429
445
|
|
|
430
446
|
lli watch --include "*my-custom-api.com*"
|
|
431
447
|
|
|
432
|
-
Configure your target application to use this proxy:
|
|
448
|
+
Configure your target application to use this proxy (replace the port as needed):
|
|
433
449
|
|
|
434
450
|
export HTTP_PROXY=http://127.0.0.1:9090
|
|
435
451
|
|
|
@@ -442,8 +458,12 @@ def watch(
|
|
|
442
458
|
# Load configuration
|
|
443
459
|
config = load_config(ctx.obj.get("config_path"))
|
|
444
460
|
|
|
445
|
-
# Apply CLI overrides
|
|
446
|
-
|
|
461
|
+
# Apply CLI overrides only when explicitly provided
|
|
462
|
+
port_source = ctx.get_parameter_source("port")
|
|
463
|
+
if port_source == click.core.ParameterSource.DEFAULT:
|
|
464
|
+
port = config.proxy.port
|
|
465
|
+
else:
|
|
466
|
+
config.proxy.port = port
|
|
447
467
|
|
|
448
468
|
# Add custom glob patterns (user-provided via CLI)
|
|
449
469
|
for pattern in include:
|
|
@@ -475,9 +495,6 @@ def watch(
|
|
|
475
495
|
if ui:
|
|
476
496
|
from cci.server import run_server
|
|
477
497
|
|
|
478
|
-
ui_host = "127.0.0.1"
|
|
479
|
-
ui_port = 8000 # TODO: Make configurable
|
|
480
|
-
|
|
481
498
|
if _is_port_in_use(ui_host, ui_port):
|
|
482
499
|
ui_url = f"http://{ui_host}:{ui_port}"
|
|
483
500
|
console.print(
|
|
@@ -493,7 +510,7 @@ def watch(
|
|
|
493
510
|
server_thread = threading.Thread(
|
|
494
511
|
target=run_server,
|
|
495
512
|
args=(watch_manager,),
|
|
496
|
-
kwargs={"port": ui_port},
|
|
513
|
+
kwargs={"host": ui_host, "port": ui_port},
|
|
497
514
|
daemon=True,
|
|
498
515
|
)
|
|
499
516
|
server_thread.start()
|
|
@@ -620,6 +637,12 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
620
637
|
"""Run the main watch mode interaction loop."""
|
|
621
638
|
from cci.watch import WatchState
|
|
622
639
|
|
|
640
|
+
def _recording_status_text(session_id: str) -> str:
|
|
641
|
+
return (
|
|
642
|
+
f"[bold red]◉[/] [red][REC][/] Session [bold]{session_id}[/] recording "
|
|
643
|
+
f"[dim]Press [Enter] to STOP & PROCESS, [Esc] to CANCEL[/]"
|
|
644
|
+
)
|
|
645
|
+
|
|
623
646
|
while not stop_event.is_set():
|
|
624
647
|
state = watch_manager.state
|
|
625
648
|
|
|
@@ -644,13 +667,17 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
644
667
|
session = watch_manager.start_recording()
|
|
645
668
|
session_id = session.session_id
|
|
646
669
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
f"
|
|
650
|
-
f"[dim]Press [Enter] to STOP & PROCESS, [Esc] to CANCEL[/]"
|
|
670
|
+
console.print(
|
|
671
|
+
f"\n[bold green]▶[/] [green][START][/] Session [bold]{session_id}[/] "
|
|
672
|
+
f"recording (requests: {session.request_count})"
|
|
651
673
|
)
|
|
652
674
|
|
|
653
|
-
|
|
675
|
+
# Display RECORDING status once to avoid spinner refresh noise
|
|
676
|
+
with console.status(
|
|
677
|
+
_recording_status_text(session_id),
|
|
678
|
+
spinner="point",
|
|
679
|
+
spinner_style="red",
|
|
680
|
+
):
|
|
654
681
|
try:
|
|
655
682
|
key = _wait_for_enter_or_escape()
|
|
656
683
|
except EOFError:
|
|
@@ -660,10 +687,11 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
660
687
|
break
|
|
661
688
|
|
|
662
689
|
if key == "escape":
|
|
663
|
-
watch_manager.cancel_recording()
|
|
690
|
+
cancelled = watch_manager.cancel_recording()
|
|
664
691
|
console.print(
|
|
665
692
|
f"\n[bold yellow]✖[/] [yellow][CANCEL][/]"
|
|
666
|
-
f" Session [bold]{session_id}[/] cancelled
|
|
693
|
+
f" Session [bold]{session_id}[/] cancelled "
|
|
694
|
+
f"(requests: {cancelled.request_count})."
|
|
667
695
|
)
|
|
668
696
|
console.print()
|
|
669
697
|
else:
|
|
@@ -671,7 +699,8 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
671
699
|
session = watch_manager.stop_recording()
|
|
672
700
|
console.print(
|
|
673
701
|
f"\n[bold yellow]⏳[/] [yellow][BUSY][/] Processing "
|
|
674
|
-
f"Session [bold]{session_id}[/]
|
|
702
|
+
f"Session [bold]{session_id}[/] "
|
|
703
|
+
f"(requests: {session.request_count})..."
|
|
675
704
|
)
|
|
676
705
|
|
|
677
706
|
# Process the session
|
|
@@ -688,12 +717,11 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
688
717
|
session_id = (
|
|
689
718
|
watch_manager.current_session.session_id if watch_manager.current_session else "?"
|
|
690
719
|
)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
with console.status(rec_status_text, spinner="point", spinner_style="red"):
|
|
720
|
+
with console.status(
|
|
721
|
+
_recording_status_text(session_id),
|
|
722
|
+
spinner="point",
|
|
723
|
+
spinner_style="red",
|
|
724
|
+
):
|
|
697
725
|
try:
|
|
698
726
|
key = _wait_for_enter_or_escape()
|
|
699
727
|
except EOFError:
|
|
@@ -707,7 +735,8 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
707
735
|
cancelled = watch_manager.cancel_recording()
|
|
708
736
|
console.print(
|
|
709
737
|
f"\n[bold yellow]✖[/] [yellow][CANCEL][/]"
|
|
710
|
-
f" Session [bold]{cancelled.session_id}[/] cancelled
|
|
738
|
+
f" Session [bold]{cancelled.session_id}[/] cancelled "
|
|
739
|
+
f"(requests: {cancelled.request_count})."
|
|
711
740
|
)
|
|
712
741
|
console.print()
|
|
713
742
|
else:
|
|
@@ -716,7 +745,8 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
716
745
|
session_id = session.session_id
|
|
717
746
|
console.print(
|
|
718
747
|
f"\n[bold yellow]⏳[/] [yellow][BUSY][/] Processing "
|
|
719
|
-
f"Session [bold]{session_id}[/]
|
|
748
|
+
f"Session [bold]{session_id}[/] "
|
|
749
|
+
f"(requests: {session.request_count})..."
|
|
720
750
|
)
|
|
721
751
|
|
|
722
752
|
# Process the session
|
|
@@ -733,28 +763,46 @@ def _run_watch_loop(watch_manager: WatchManager, stop_event: threading.Event) ->
|
|
|
733
763
|
time.sleep(0.1)
|
|
734
764
|
|
|
735
765
|
|
|
736
|
-
def _wait_for_enter_or_escape(
|
|
766
|
+
def _wait_for_enter_or_escape(
|
|
767
|
+
timeout: float | None = None,
|
|
768
|
+
) -> Literal["enter", "escape"] | None:
|
|
737
769
|
"""
|
|
738
770
|
Wait for a single keypress: Enter or Escape.
|
|
739
771
|
|
|
740
772
|
Falls back to line-buffered input when stdin is not a TTY (e.g. piped).
|
|
741
773
|
"""
|
|
742
774
|
if not sys.stdin.isatty():
|
|
743
|
-
|
|
744
|
-
|
|
775
|
+
if timeout is None:
|
|
776
|
+
input()
|
|
777
|
+
return "enter"
|
|
778
|
+
return None
|
|
745
779
|
|
|
746
780
|
if sys.platform.startswith("win"):
|
|
747
781
|
# Windows: use msvcrt for unbuffered key input
|
|
748
782
|
import msvcrt
|
|
783
|
+
import time
|
|
749
784
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
785
|
+
if timeout is None:
|
|
786
|
+
while True:
|
|
787
|
+
ch = msvcrt.getwch()
|
|
788
|
+
if ch in ("\r", "\n"):
|
|
789
|
+
return "enter"
|
|
790
|
+
if ch == "\x1b":
|
|
791
|
+
return "escape"
|
|
792
|
+
else:
|
|
793
|
+
end_time = time.time() + timeout
|
|
794
|
+
while time.time() < end_time:
|
|
795
|
+
if msvcrt.kbhit():
|
|
796
|
+
ch = msvcrt.getwch()
|
|
797
|
+
if ch in ("\r", "\n"):
|
|
798
|
+
return "enter"
|
|
799
|
+
if ch == "\x1b":
|
|
800
|
+
return "escape"
|
|
801
|
+
time.sleep(0.01)
|
|
802
|
+
return None
|
|
756
803
|
else:
|
|
757
804
|
# POSIX: temporarily switch terminal to raw mode
|
|
805
|
+
import select
|
|
758
806
|
import termios
|
|
759
807
|
import tty
|
|
760
808
|
|
|
@@ -762,7 +810,19 @@ def _wait_for_enter_or_escape() -> Literal["enter", "escape"]:
|
|
|
762
810
|
old = termios.tcgetattr(fd)
|
|
763
811
|
try:
|
|
764
812
|
tty.setraw(fd)
|
|
765
|
-
|
|
813
|
+
if timeout is None:
|
|
814
|
+
while True:
|
|
815
|
+
rlist, _, _ = select.select([fd], [], [])
|
|
816
|
+
if rlist:
|
|
817
|
+
ch = sys.stdin.read(1)
|
|
818
|
+
if ch in ("\r", "\n"):
|
|
819
|
+
return "enter"
|
|
820
|
+
if ch == "\x1b":
|
|
821
|
+
return "escape"
|
|
822
|
+
else:
|
|
823
|
+
rlist, _, _ = select.select([fd], [], [], timeout)
|
|
824
|
+
if not rlist:
|
|
825
|
+
return None
|
|
766
826
|
ch = sys.stdin.read(1)
|
|
767
827
|
if ch in ("\r", "\n"):
|
|
768
828
|
return "enter"
|
|
@@ -771,6 +831,8 @@ def _wait_for_enter_or_escape() -> Literal["enter", "escape"]:
|
|
|
771
831
|
finally:
|
|
772
832
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
773
833
|
|
|
834
|
+
return None
|
|
835
|
+
|
|
774
836
|
|
|
775
837
|
def _is_port_in_use(host: str, port: int) -> bool:
|
|
776
838
|
"""Return True if the given host:port combination is already bound."""
|
|
@@ -67,6 +67,7 @@ def setup_logger(
|
|
|
67
67
|
console=console,
|
|
68
68
|
show_time=True,
|
|
69
69
|
show_path=False,
|
|
70
|
+
show_level=False,
|
|
70
71
|
rich_tracebacks=True,
|
|
71
72
|
tracebacks_show_locals=level.upper() == "DEBUG",
|
|
72
73
|
markup=True,
|
|
@@ -115,10 +116,17 @@ def log_request_summary(
|
|
|
115
116
|
url: str,
|
|
116
117
|
status: int | None = None,
|
|
117
118
|
latency_ms: float | None = None,
|
|
119
|
+
captured: bool = True,
|
|
118
120
|
) -> None:
|
|
119
121
|
"""Log a formatted request summary."""
|
|
120
122
|
logger = get_logger()
|
|
121
123
|
|
|
124
|
+
# Capture status indicator
|
|
125
|
+
if captured:
|
|
126
|
+
cap_str = "[bold green][CAPT][/bold green]"
|
|
127
|
+
else:
|
|
128
|
+
cap_str = "[bold magenta][SKIP][/bold magenta]"
|
|
129
|
+
|
|
122
130
|
# Color coding based on status
|
|
123
131
|
if status is None:
|
|
124
132
|
status_str = "→"
|
|
@@ -131,7 +139,7 @@ def log_request_summary(
|
|
|
131
139
|
|
|
132
140
|
latency_str = f" ({latency_ms:.0f}ms)" if latency_ms else ""
|
|
133
141
|
|
|
134
|
-
logger.info(f"{method} {url} {status_str}{latency_str}")
|
|
142
|
+
logger.info(f"{cap_str} {method} {url} {status_str}{latency_str}")
|
|
135
143
|
|
|
136
144
|
|
|
137
145
|
def log_streaming_progress(request_id: str, chunk_count: int) -> None:
|
|
@@ -64,19 +64,26 @@ class WatchAddon:
|
|
|
64
64
|
def request(self, flow: http.HTTPFlow) -> None:
|
|
65
65
|
"""Handle an outgoing request."""
|
|
66
66
|
url = flow.request.pretty_url
|
|
67
|
+
method = flow.request.method
|
|
67
68
|
|
|
68
|
-
self._logger.debug("Intercepted request: %s %s",
|
|
69
|
+
self._logger.debug("Intercepted request: %s %s", method, url)
|
|
69
70
|
|
|
70
|
-
if
|
|
71
|
-
|
|
72
|
-
return
|
|
71
|
+
# Determine if we should capture this request
|
|
72
|
+
should_capture = self.url_filter.should_capture(url)
|
|
73
73
|
|
|
74
|
-
#
|
|
74
|
+
# Log summary for all requests
|
|
75
|
+
log_request_summary(method, url, captured=should_capture)
|
|
76
|
+
|
|
77
|
+
# Generate unique request ID and track timing for all requests
|
|
75
78
|
request_id = str(uuid4())
|
|
76
79
|
flow_id = id(flow)
|
|
77
80
|
self._request_ids[flow_id] = request_id
|
|
78
81
|
self._request_times[flow_id] = time.time()
|
|
79
82
|
|
|
83
|
+
if not should_capture:
|
|
84
|
+
self._logger.debug("URL not matched, skipping: %s", url)
|
|
85
|
+
return
|
|
86
|
+
|
|
80
87
|
# Capture current session ID for this request
|
|
81
88
|
session_id = self.watch_manager.current_session_id
|
|
82
89
|
self._request_sessions[flow_id] = session_id
|
|
@@ -96,7 +103,7 @@ class WatchAddon:
|
|
|
96
103
|
"type": "request",
|
|
97
104
|
"id": request_id,
|
|
98
105
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
99
|
-
"method":
|
|
106
|
+
"method": method,
|
|
100
107
|
"url": url,
|
|
101
108
|
"headers": headers,
|
|
102
109
|
"body": body,
|
|
@@ -108,14 +115,31 @@ class WatchAddon:
|
|
|
108
115
|
def response(self, flow: http.HTTPFlow) -> None:
|
|
109
116
|
"""Handle a response."""
|
|
110
117
|
url = flow.request.pretty_url
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
method = flow.request.method
|
|
119
|
+
status_code = flow.response.status_code
|
|
120
|
+
|
|
121
|
+
# Determine if we should capture this response
|
|
122
|
+
should_capture = self.url_filter.should_capture(url)
|
|
113
123
|
|
|
114
124
|
flow_id = id(flow)
|
|
115
125
|
request_id = self._request_ids.get(flow_id, str(uuid4()))
|
|
116
126
|
start_time = self._request_times.get(flow_id, time.time())
|
|
117
127
|
latency_ms = (time.time() - start_time) * 1000
|
|
118
128
|
|
|
129
|
+
# Log summary for all responses
|
|
130
|
+
log_request_summary(
|
|
131
|
+
method,
|
|
132
|
+
url,
|
|
133
|
+
status_code,
|
|
134
|
+
latency_ms,
|
|
135
|
+
captured=should_capture,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not should_capture:
|
|
139
|
+
# Cleanup and exit early if not capturing
|
|
140
|
+
self._cleanup_flow(flow_id)
|
|
141
|
+
return
|
|
142
|
+
|
|
119
143
|
# Use the session ID from the request start
|
|
120
144
|
session_id = self._request_sessions.get(flow_id)
|
|
121
145
|
|
|
@@ -125,7 +149,7 @@ class WatchAddon:
|
|
|
125
149
|
|
|
126
150
|
self._logger.debug(
|
|
127
151
|
"Response received: %s %s (streaming=%s, content-type=%s)",
|
|
128
|
-
|
|
152
|
+
status_code,
|
|
129
153
|
url,
|
|
130
154
|
is_streaming,
|
|
131
155
|
content_type,
|
|
@@ -141,7 +165,7 @@ class WatchAddon:
|
|
|
141
165
|
"type": "response_chunk",
|
|
142
166
|
"request_id": request_id,
|
|
143
167
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
144
|
-
"status_code":
|
|
168
|
+
"status_code": status_code,
|
|
145
169
|
"chunk_index": chunk_index,
|
|
146
170
|
"content": event_content,
|
|
147
171
|
}
|
|
@@ -153,7 +177,7 @@ class WatchAddon:
|
|
|
153
177
|
"type": "response_meta",
|
|
154
178
|
"request_id": request_id,
|
|
155
179
|
"total_latency_ms": latency_ms,
|
|
156
|
-
"status_code":
|
|
180
|
+
"status_code": status_code,
|
|
157
181
|
"total_chunks": len(sse_events),
|
|
158
182
|
}
|
|
159
183
|
self.watch_manager.write_record(meta_record, session_id=session_id)
|
|
@@ -168,20 +192,13 @@ class WatchAddon:
|
|
|
168
192
|
"type": "response",
|
|
169
193
|
"request_id": request_id,
|
|
170
194
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
171
|
-
"status_code":
|
|
195
|
+
"status_code": status_code,
|
|
172
196
|
"headers": headers,
|
|
173
197
|
"body": body,
|
|
174
198
|
"latency_ms": latency_ms,
|
|
175
199
|
}
|
|
176
200
|
self.watch_manager.write_record(record, session_id=session_id)
|
|
177
201
|
|
|
178
|
-
log_request_summary(
|
|
179
|
-
flow.request.method,
|
|
180
|
-
url,
|
|
181
|
-
flow.response.status_code,
|
|
182
|
-
latency_ms,
|
|
183
|
-
)
|
|
184
|
-
|
|
185
202
|
# Cleanup
|
|
186
203
|
self._cleanup_flow(flow_id)
|
|
187
204
|
|
|
@@ -23,6 +23,11 @@ This UI is bundled with the `llm-interceptor` Python package.
|
|
|
23
23
|
lli watch --include "*example.com*"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
To expose the UI on your network:
|
|
27
|
+
```bash
|
|
28
|
+
lli watch --ui-host 0.0.0.0 --ui-port 8000
|
|
29
|
+
```
|
|
30
|
+
|
|
26
31
|
2. **Open the Dashboard**:
|
|
27
32
|
The CLI will print a URL (usually `http://localhost:8000`). Click it to open the dashboard.
|
|
28
33
|
|