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.
Files changed (70) hide show
  1. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/CHANGELOG.md +16 -0
  2. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/PKG-INFO +1 -1
  3. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/cci.example.toml +1 -1
  4. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/lli.example.toml +1 -1
  5. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/pyproject.toml +1 -1
  6. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/__init__.py +1 -1
  7. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/cli.py +103 -41
  8. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/config.py +1 -1
  9. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/logger.py +9 -1
  10. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/proxy.py +36 -19
  11. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/README.md +5 -0
  12. llm_interceptor-2.3.0/ui/src/components/layout/SessionsSidebar.tsx +311 -0
  13. llm_interceptor-2.2.0/ui/src/components/layout/SessionsSidebar.tsx +0 -271
  14. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.github/workflows/ci.yml +0 -0
  15. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.github/workflows/publish.yml +0 -0
  16. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.gitignore +0 -0
  17. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/.pre-commit-config.yaml +0 -0
  18. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/README.md +0 -0
  19. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/build_ui.py +0 -0
  20. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/cci-ui-screenshot.png +0 -0
  21. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/app.py +0 -0
  22. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/filters.py +0 -0
  23. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/merger.py +0 -0
  24. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/models.py +0 -0
  25. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/server.py +0 -0
  26. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/splitter.py +0 -0
  27. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/storage.py +0 -0
  28. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/cci/watch.py +0 -0
  29. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/llm_interceptor/__init__.py +0 -0
  30. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/src/llm_interceptor/cli.py +0 -0
  31. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/__init__.py +0 -0
  32. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_basic.py +0 -0
  33. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_merger.py +0 -0
  34. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/tests/test_watch_cancel.py +0 -0
  35. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/.gitignore +0 -0
  36. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/eslint.config.js +0 -0
  37. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/index.html +0 -0
  38. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/package-lock.json +0 -0
  39. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/package.json +0 -0
  40. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/postcss.config.js +0 -0
  41. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/public/cci-icon.png +0 -0
  42. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/public/vite.svg +0 -0
  43. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/App.css +0 -0
  44. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/App.tsx +0 -0
  45. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/assets/react.svg +0 -0
  46. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/ChatBubble.tsx +0 -0
  47. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/MessageContent.tsx +0 -0
  48. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/chat/ToolCallBlock.tsx +0 -0
  49. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/CopyButton.tsx +0 -0
  50. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/JSONViewer.tsx +0 -0
  51. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/TokenBadge.tsx +0 -0
  52. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/common/Tooltip.tsx +0 -0
  53. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/EmptyState.tsx +0 -0
  54. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/ExchangeDetailsPane.tsx +0 -0
  55. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/layout/RequestsPane.tsx +0 -0
  56. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/components/tools/ToolsList.tsx +0 -0
  57. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useAnnotations.ts +0 -0
  58. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useResizablePanels.ts +0 -0
  59. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useSessions.ts +0 -0
  60. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/hooks/useTheme.ts +0 -0
  61. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/index.css +0 -0
  62. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/main.tsx +0 -0
  63. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/types.ts +0 -0
  64. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/utils/ui.ts +0 -0
  65. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/src/utils.ts +0 -0
  66. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tailwind.config.js +0 -0
  67. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.app.json +0 -0
  68. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.json +0 -0
  69. {llm_interceptor-2.2.0 → llm_interceptor-2.3.0}/ui/tsconfig.node.json +0 -0
  70. {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.2.0
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
@@ -5,7 +5,7 @@
5
5
  # Proxy server binding address
6
6
  host = "127.0.0.1"
7
7
  # Proxy server port
8
- port = 8080
8
+ port = 9090
9
9
  # Skip SSL verification for upstream connections (not recommended for production)
10
10
  ssl_insecure = false
11
11
 
@@ -7,7 +7,7 @@
7
7
  # Proxy server binding address
8
8
  host = "127.0.0.1"
9
9
  # Proxy server port
10
- port = 8080
10
+ port = 9090
11
11
  # Skip SSL verification for upstream connections (not recommended for production)
12
12
  ssl_insecure = false
13
13
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "llm-interceptor"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Intercept and analyze LLM traffic from AI coding tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -7,5 +7,5 @@ Note: The import package name `cci` is kept for backward compatibility.
7
7
  New code should prefer `llm_interceptor`.
8
8
  """
9
9
 
10
- __version__ = "2.2.0"
10
+ __version__ = "2.3.0"
11
11
  __author__ = "LLM Interceptor Team"
@@ -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:8080")
277
- console.print(" export HTTPS_PROXY=http://127.0.0.1:8080")
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:8080")
283
- console.print(" export HTTPS_PROXY=http://127.0.0.1:8080")
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:8080")
290
- console.print(" export HTTPS_PROXY=http://127.0.0.1:8080")
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:8080 https://api.anthropic.com/v1/messages ...")
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:8080", "https": "http://127.0.0.1:8080"}')
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
- config.proxy.port = port
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
- # Display RECORDING status with spinner pinned at bottom
648
- rec_status_text = (
649
- f"[bold red]◉[/] [red][REC][/] Session [bold]{session_id}[/] recording "
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
- with console.status(rec_status_text, spinner="point", spinner_style="red"):
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 (no output generated)."
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
- rec_status_text = (
692
- f"[bold red]◉[/] [red][REC][/] Session [bold]{session_id}[/] recording "
693
- f"[dim]Press [Enter] to STOP & PROCESS, [Esc] to CANCEL[/]"
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 (no output generated)."
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() -> Literal["enter", "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
- input()
744
- return "enter"
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
- while True:
751
- ch = msvcrt.getwch()
752
- if ch in ("\r", "\n"):
753
- return "enter"
754
- if ch == "\x1b":
755
- return "escape"
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
- while True:
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."""
@@ -61,7 +61,7 @@ class ProxyConfig(BaseModel):
61
61
  """Proxy server configuration."""
62
62
 
63
63
  host: str = "127.0.0.1"
64
- port: int = 8080
64
+ port: int = 9090
65
65
  # SSL verification mode
66
66
  ssl_insecure: bool = False
67
67
 
@@ -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", flow.request.method, url)
69
+ self._logger.debug("Intercepted request: %s %s", method, url)
69
70
 
70
- if not self.url_filter.should_capture(url):
71
- self._logger.debug("URL not matched, skipping: %s", url)
72
- return
71
+ # Determine if we should capture this request
72
+ should_capture = self.url_filter.should_capture(url)
73
73
 
74
- # Generate unique request ID
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": flow.request.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
- if not self.url_filter.should_capture(url):
112
- return
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
- flow.response.status_code,
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": flow.response.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": flow.response.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": flow.response.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