whatap-python 2.2.0__py3-none-any.whl → 2.2.1__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.
Files changed (62) hide show
  1. whatap/__init__.py +180 -33
  2. whatap/build.py +2 -2
  3. whatap/conf/configuration.py +72 -1
  4. whatap/counter/tasks/llm_stat_task.py +10 -0
  5. whatap/doctor.py +402 -0
  6. whatap/llm/control/__init__.py +59 -0
  7. whatap/llm/control/cut.py +519 -0
  8. whatap/llm/control/guard.py +217 -0
  9. whatap/llm/control/trajectory.py +524 -0
  10. whatap/llm/definitions.py +21 -1
  11. whatap/llm/instrument/_boundary.py +136 -22
  12. whatap/llm/log_sink_packs/llm_step_status.py +9 -0
  13. whatap/llm/pricing.py +16 -1
  14. whatap/llm/providers/anthropic/messages/messages.py +3 -2
  15. whatap/llm/providers/anthropic/messages/messages_stream.py +3 -2
  16. whatap/llm/providers/bedrock/__init__.py +92 -0
  17. whatap/llm/providers/bedrock/_content.py +81 -0
  18. whatap/llm/providers/bedrock/converse/__init__.py +2 -0
  19. whatap/llm/providers/bedrock/converse/converse_context.py +155 -0
  20. whatap/llm/providers/bedrock/converse/converse_extractor.py +196 -0
  21. whatap/llm/providers/bedrock/event_stream.py +254 -0
  22. whatap/llm/providers/bedrock/http.py +395 -0
  23. whatap/llm/providers/bedrock/invoke_model/__init__.py +2 -0
  24. whatap/llm/providers/bedrock/invoke_model/invoke_model_context.py +126 -0
  25. whatap/llm/providers/bedrock/invoke_model/invoke_model_extractor.py +763 -0
  26. whatap/llm/providers/bedrock/runtime.py +291 -0
  27. whatap/llm/providers/bedrock/step.py +76 -0
  28. whatap/llm/providers/interceptor.py +10 -1
  29. whatap/llm/providers/openai/chat/chat.py +71 -14
  30. whatap/llm/providers/openai/chat/chat_extractor.py +8 -1
  31. whatap/llm/providers/openai/completions/completions.py +3 -2
  32. whatap/llm/providers/openai/embeddings/embeddings.py +3 -2
  33. whatap/llm/providers/openai/responses/responses.py +3 -2
  34. whatap/llm/providers/stream_accumulator.py +11 -1
  35. whatap/llm/providers/workflow.py +87 -5
  36. whatap/llm/stats/traj_actx_stat.py +50 -1
  37. whatap/net/async_sender.py +22 -9
  38. whatap/net/param_def.py +6 -0
  39. whatap/net/udp_session.py +33 -0
  40. whatap/scripts/__init__.py +89 -0
  41. whatap/skills/whatap-python-install/AGENTS.md +29 -0
  42. whatap/skills/whatap-python-install/INSTALL.md +96 -0
  43. whatap/skills/whatap-python-install/SKILL.md +42 -0
  44. whatap/skills/whatap-python-install/references/config-keys.md +59 -0
  45. whatap/skills/whatap-python-install/references/frameworks.md +55 -0
  46. whatap/skills/whatap-python-install/references/llm-api-usage.md +81 -0
  47. whatap/skills/whatap-python-install/references/llm-evaluation.md +114 -0
  48. whatap/skills/whatap-python-install/references/llm-observability.md +123 -0
  49. whatap/skills/whatap-python-install/references/troubleshooting.md +41 -0
  50. whatap/trace/mod/httpc/httpx.py +26 -3
  51. whatap/trace/mod/httpc/httpx2.py +23 -0
  52. whatap/trace/mod/httpc/requests.py +16 -3
  53. whatap/trace/mod/httpc/stream_end.py +136 -0
  54. whatap/trace/mod/httpc/util.py +231 -13
  55. whatap/trace/trace_error.py +9 -2
  56. whatap/trace/trace_module_definition.py +15 -0
  57. whatap/util/native_process.py +458 -0
  58. {whatap_python-2.2.0.dist-info → whatap_python-2.2.1.dist-info}/METADATA +1 -1
  59. {whatap_python-2.2.0.dist-info → whatap_python-2.2.1.dist-info}/RECORD +62 -33
  60. {whatap_python-2.2.0.dist-info → whatap_python-2.2.1.dist-info}/entry_points.txt +2 -0
  61. {whatap_python-2.2.0.dist-info → whatap_python-2.2.1.dist-info}/WHEEL +0 -0
  62. {whatap_python-2.2.0.dist-info → whatap_python-2.2.1.dist-info}/top_level.txt +0 -0
whatap/__init__.py CHANGED
@@ -378,11 +378,20 @@ def _is_instrumentation_ignored(definition_key, ignore_set):
378
378
  def hooks(home):
379
379
  ignore_set = _build_ignore_instrumentation_set()
380
380
  skipped_keys = []
381
+ # LLM 계측은 llm_enabled 일 때만 **등록**한다. instrument_* 함수들도 각자
382
+ # llm_enabled 를 보고 되돌아가지만, 등록만 해둬도 대상 모듈이 로드되는 순간
383
+ # import 훅이 ``whatap.llm.*`` 서브트리를 통째로 끌어온다 — 켜지도 않은 기능
384
+ # 때문에. ``botocore.client``(Bedrock) 처럼 LLM 과 무관한 앱도 흔히 쓰는 모듈이
385
+ # 대상에 들어오면서 이 비용이 실제로 문제가 된다.
386
+ # conf.init() 이 hooks() 보다 먼저 돌므로 여기서 llm_enabled 는 이미 확정이다.
387
+ llm_enabled = bool(getattr(conf, 'llm_enabled', False))
381
388
  try:
382
389
  for key, value_list in DEFINITION.items():
383
390
  if _is_instrumentation_ignored(key, ignore_set):
384
391
  skipped_keys.append(key)
385
392
  continue
393
+ if key.startswith('llm.') and not llm_enabled:
394
+ continue
386
395
  for value in value_list:
387
396
  if len(value) >= 3 and isinstance(value[2], str):
388
397
  module_path = value[2]
@@ -537,9 +546,14 @@ def agent():
537
546
  llm_enabled = preview_whatap_conf("llm_enabled")
538
547
 
539
548
  if apm_enabled == 'true':
540
- t = threading.Thread(target=go)
541
- t.setDaemon(True)
542
- t.start()
549
+ if sys.platform.startswith('linux'):
550
+ # Publish native ownership before a short-lived interpreter
551
+ # can exit. This does not wait for collector/TCP readiness.
552
+ go()
553
+ else:
554
+ t = threading.Thread(target=go)
555
+ t.setDaemon(True)
556
+ t.start()
543
557
 
544
558
  if llm_enabled == 'true':
545
559
  # llm_enabled=true 라도 LLM 채널 자격증명(llm_accesskey)이 없으면 LLM
@@ -564,24 +578,134 @@ ARCH = {
564
578
  'ARM': 'arm',
565
579
  'AArch64': 'arm64',
566
580
  'arm64': 'arm64',
567
- 'aarch64': 'arm64'
581
+ 'aarch64': 'arm64',
582
+ # Windows 의 platform.machine() 은 **대문자**로 돌려준다('AMD64'/'ARM64').
583
+ # Windows 는 arch 하위 디렉토리를 쓰지 않아 지금은 이 표를 안 타지만, 빠져 있으면
584
+ # 누가 조회하는 순간 KeyError 로 기동이 통째로 죽는 종류의 구멍이라 채워 둔다.
585
+ 'AMD64': 'amd64',
586
+ 'ARM64': 'arm64',
587
+ 'i386': '386',
568
588
  }
569
589
 
570
590
  AGENT_NAME = 'whatap_python'
571
591
 
572
592
 
593
+ def agent_binary_name():
594
+ """OS 별 에이전트 실행 파일 이름."""
595
+ return AGENT_NAME + '.exe' if sys.platform == 'win32' else AGENT_NAME
596
+
597
+
598
+ def llm_agent_reldir():
599
+ """LLM 전용 ``whatap_python`` 이 놓이는 ``$WHATAP_HOME`` 하위 상대경로.
600
+
601
+ Unix 계열은 ``<os>/<arch>`` (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64).
602
+ **Windows 는 arch 하위를 쓰지 않는다** — x64 하나면 ARM64 Windows 에서도 에뮬레이션
603
+ 으로 돌아서 아키텍처별 빌드를 나눌 이유가 없다. 번들 APM 쪽
604
+ ``whatap/agent/windows/`` 가 평평한 것과 같은 이유이고, 같은 모양이다.
605
+
606
+ 반환: ``(상대경로, 사유)``. 상대경로가 None 이면 사유에 왜 못 구하는지가 담긴다.
607
+ """
608
+ system = platform.system().lower()
609
+ if sys.platform == 'win32':
610
+ return system, ''
611
+ machine = platform.machine()
612
+ arch = ARCH.get(machine)
613
+ if not arch:
614
+ return None, ('unsupported architecture {0!r} for the LLM agent '
615
+ '(known: {1})'.format(machine, ', '.join(sorted(set(ARCH.values())))))
616
+ return os.path.join(system, arch), ''
617
+
618
+
619
+ def llm_agent_path():
620
+ """LLM 전용 ``whatap_python`` 의 배포 위치. @see ``llm_agent_reldir``
621
+
622
+ 반환: ``(경로, 사유)``. 경로가 None 이면 사유에 왜 못 쓰는지가 담긴다.
623
+ """
624
+ reldir, reason = llm_agent_reldir()
625
+ if reldir is None:
626
+ return None, reason
627
+ home_path = os.environ.get('WHATAP_HOME')
628
+ if not home_path:
629
+ return None, 'WHATAP_HOME is not set'
630
+ path = os.path.join(home_path, reldir, agent_binary_name())
631
+ if not os.path.exists(path):
632
+ return None, ('LLM agent binary not found: {0} '
633
+ '(place the LLM {1} under $WHATAP_HOME/{2}/)'.format(
634
+ path, agent_binary_name(), reldir.replace(os.sep, '/')))
635
+ return path, ''
636
+
637
+
638
+
639
+ def _native_agent_spec(opts):
640
+ from whatap.util.native_process import AgentSpec
641
+ source = os.path.join(os.path.dirname(__file__), 'agent', 'linux',
642
+ ARCH[platform.machine()], AGENT_NAME)
643
+ env = os.environ.copy()
644
+ env.update(opts)
645
+ return AgentSpec(os.environ['WHATAP_HOME'], source, env)
646
+
647
+
648
+ def _native_agent_log_path():
649
+ return os.path.join(os.environ.get('WHATAP_HOME', '.'), 'logs', AGENT_NAME + '.log')
650
+
651
+
652
+ def _open_native_agent_log():
653
+ # managed 경로는 PIPE 를 읽지 않으므로 네이티브 stdout/stderr 를 파일로 남겨
654
+ # 기동 직후 실패 원인을 추적할 수 있게 한다.
655
+ try:
656
+ log_path = _native_agent_log_path()
657
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
658
+ return open(log_path, 'ab')
659
+ except Exception as e:
660
+ whatap_print('WHATAP: native agent log open failed: %s' % e)
661
+ return subprocess.DEVNULL
662
+
573
663
 
574
664
  def go(batch=False, opts={}, llm=False):
575
- # LLM 에이전트는 Linux/macOS 를 지원한다(전용 whatap_python 이
576
- # $WHATAP_HOME/<os>/<arch> 에 배포됨 — linux/darwin × amd64/arm64).
577
- # 그 외 OS(Windows 등)는 전용 바이너리가 없으므로 우회적 "binary not found"
578
- # 대신 명시적으로 LLM 기동을 차단하고 알린다.
579
- if llm and platform.system().lower() not in ('linux', 'darwin'):
580
- whatap_error_log(
581
- 'WHATAP: LLM agent is supported on Linux/macOS only '
582
- '(current OS: {0}); skipping LLM agent start'.format(platform.system()),
583
- log_id='WHATAP_LLM_UNSUPPORTED_OS')
665
+ if sys.platform.startswith('linux') and not batch and not llm:
666
+ from whatap.util.native_process import FAILED, ensure_agent_running
667
+
668
+ def on_child_exit(pid, exit_code):
669
+ whatap_error_log(
670
+ 'WHATAP: native APM exited (PID: {}, exit code: {}, log: {})'.format(
671
+ pid, exit_code, _native_agent_log_path()),
672
+ log_id='WHATAP_AGENT_EXITED')
673
+
674
+ try:
675
+ spec = _native_agent_spec(opts)
676
+ result = ensure_agent_running(
677
+ spec, lambda: _start_go(batch, opts, llm, managed=True),
678
+ on_child_exit=on_child_exit)
679
+ if result.status == FAILED:
680
+ whatap_error_log('WHATAP: native APM startup failed',
681
+ log_id='WHATAP_AGENT_START_FAILED')
682
+ else:
683
+ whatap_print('WHATAP: native APM {} (PID: {})'.format(
684
+ result.status, result.pid))
685
+ except Exception as e:
686
+ whatap_error_log('WHATAP: native APM startup failed: {}'.format(e),
687
+ log_id='WHATAP_AGENT_START_FAILED')
584
688
  return
689
+ return _start_go(batch, opts, llm)
690
+
691
+
692
+ def _start_go(batch=False, opts={}, llm=False, managed=False):
693
+ # LLM 에이전트는 전용 whatap_python 이 $WHATAP_HOME 하위에 따로 배포된다
694
+ # (unix 는 <os>/<arch>, windows 는 windows/ — @see llm_agent_reldir).
695
+ # **OS 로 막지 않는다** — 그 자리에 바이너리가 놓였는지로 판단한다(그래야 windows
696
+ # 빌드가 배포되는 순간 코드 변경 없이 살아난다).
697
+ #
698
+ # 여기서 미리 걸러야 하는 이유: 그냥 흘려보내면 배포 분기로 빠져 **번들 APM
699
+ # 바이너리**를 골라 ``--llm`` 으로 띄우고, 심지어 APM 과 같은
700
+ # ``$WHATAP_HOME/whatap_python`` 에 배포해 서로를 덮는다(둘은 다른 바이너리다).
701
+ llm_agent_cmd = None
702
+ if llm:
703
+ llm_agent_cmd, reason = llm_agent_path()
704
+ if llm_agent_cmd is None:
705
+ whatap_error_log(
706
+ 'WHATAP: skipping LLM agent start — {0}'.format(reason),
707
+ log_id='WHATAP_LLM_AGENT_UNAVAILABLE')
708
+ return
585
709
 
586
710
  newenv=os.environ.copy()
587
711
  newenv['WHATAP_VERSION'] = build.version
@@ -625,7 +749,7 @@ def go(batch=False, opts={}, llm=False):
625
749
  return ''
626
750
 
627
751
  pid = read_file(home, file_name)
628
- if pid and get_pname(pid).find('whatap_python') >= 0:
752
+ if not managed and pid and get_pname(pid).find('whatap_python') >= 0:
629
753
  if sys.platform == 'win32':
630
754
  # Windows: Don't kill, just skip
631
755
  whatap_print('WHATAP: Agent already running (PID: {}). Skipping duplicate execution.'.format(pid))
@@ -642,24 +766,20 @@ def go(batch=False, opts={}, llm=False):
642
766
  home_path= os.environ.get(home)
643
767
 
644
768
  # Windows uses .exe extension
645
- agent_binary_name = AGENT_NAME + '.exe' if sys.platform == 'win32' else AGENT_NAME
769
+ binary_name = agent_binary_name()
646
770
 
647
- # LLM mode (Unix/Linux) runs a dedicated whatap_python binary that ships
648
- # separately under $WHATAP_HOME/<os>/<arch>/ (e.g. $WHATAP_HOME/linux/amd64).
649
- # It is executed in place from there rather than deployed from the bundled
650
- # package into $WHATAP_HOME/whatap_python, which the APM agent uses (the two
651
- # are now distinct binaries and would otherwise clobber each other).
652
- run_in_place = llm and sys.platform != 'win32'
771
+ # LLM mode runs a dedicated whatap_python binary that ships separately under
772
+ # $WHATAP_HOME (unix: <os>/<arch>, windows: windows/ — @see llm_agent_reldir).
773
+ # It is executed in place from there rather than deployed
774
+ # from the bundled package into $WHATAP_HOME/whatap_python, which the APM
775
+ # agent uses (the two are distinct binaries and would otherwise clobber each
776
+ # other). **OS 로 가르지 않는다** — Windows 만 빼면 거기서 APM 바이너리를
777
+ # 골라 --llm 으로 띄우고 APM 의 배포 경로까지 덮어쓴다.
778
+ run_in_place = llm
653
779
 
654
780
  if run_in_place:
655
- source_cwd = os.path.join(home_path, platform.system().lower(), ARCH[platform.machine()])
656
- source_agent = os.path.join(source_cwd, agent_binary_name)
657
- if not os.path.exists(source_agent):
658
- raise FileNotFoundError(
659
- 'LLM agent binary not found: {0} '
660
- '(place the LLM {1} under $WHATAP_HOME/{2}/{3}/)'.format(
661
- source_agent, agent_binary_name,
662
- platform.system().lower(), ARCH[platform.machine()]))
781
+ # 경로·존재 확인은 _start_go() 진입부의 llm_agent_path() 가 이미 했다(단일 소스).
782
+ source_agent = llm_agent_cmd
663
783
  # Ensure executable (best effort; ignore on read-only locations)
664
784
  if not os.access(source_agent, os.X_OK):
665
785
  try:
@@ -668,7 +788,7 @@ def go(batch=False, opts={}, llm=False):
668
788
  whatap_print('WHATAP: chmod LLM agent binary failed: %s' % e)
669
789
  agent_cmd = source_agent
670
790
  else:
671
- dest_agent = os.path.join(home_path, agent_binary_name)
791
+ dest_agent = os.path.join(home_path, binary_name)
672
792
 
673
793
  # Try to remove existing binary
674
794
  # Windows: File may still be locked even after waiting, ignore error and use existing binary
@@ -695,7 +815,7 @@ def go(batch=False, opts={}, llm=False):
695
815
  source_cwd = os.path.join(os.path.join(os.path.dirname(__file__), 'agent'), platform.system().lower(),
696
816
  ARCH[platform.machine()])
697
817
 
698
- source_agent = os.path.join(source_cwd, agent_binary_name)
818
+ source_agent = os.path.join(source_cwd, binary_name)
699
819
 
700
820
  # Deploy agent binary only if needed
701
821
  if needs_deployment:
@@ -712,9 +832,9 @@ def go(batch=False, opts={}, llm=False):
712
832
 
713
833
  # Windows uses absolute path, Unix/Linux uses relative path
714
834
  if sys.platform == 'win32':
715
- agent_cmd = os.path.join(home_path, agent_binary_name)
835
+ agent_cmd = os.path.join(home_path, binary_name)
716
836
  else:
717
- agent_cmd = './{0}'.format(agent_binary_name)
837
+ agent_cmd = './{0}'.format(binary_name)
718
838
 
719
839
  sockfile_path = os.path.join(home_path, 'run')
720
840
  if not os.path.exists(sockfile_path):
@@ -774,6 +894,17 @@ def go(batch=False, opts={}, llm=False):
774
894
  cwd=home_path, env=newenv,
775
895
  creationflags=DETACHED_PROCESS,
776
896
  stdout=subprocess.PIPE, stderr=subprocess.PIPE)
897
+ elif managed:
898
+ go_out = _open_native_agent_log()
899
+ try:
900
+ process = subprocess.Popen(cmd_args,
901
+ cwd=home_path, env=newenv,
902
+ start_new_session=True,
903
+ stdout=go_out, stderr=go_out)
904
+ finally:
905
+ if go_out is not subprocess.DEVNULL:
906
+ go_out.close()
907
+ return process
777
908
  else:
778
909
  process = subprocess.Popen(cmd_args,
779
910
  cwd=home_path, env=newenv,
@@ -1086,6 +1217,22 @@ def get_port_number(port=6600, home=os.environ.get('WHATAP_HOME'), lock_file=Non
1086
1217
 
1087
1218
 
1088
1219
  def configPort():
1220
+ if sys.platform.startswith('linux') and preview_whatap_conf('apm_enabled') == 'true':
1221
+ from whatap.util.native_process import acquire_start_lock, configured_channel, find_existing_agent
1222
+ try:
1223
+ spec = _native_agent_spec({})
1224
+ with acquire_start_lock(spec):
1225
+ if find_existing_agent(spec):
1226
+ # CLI allocation must not rewrite a live bootstrap agent's channel.
1227
+ return configured_channel(spec.home, spec.environment, include_unix=False)[1]
1228
+ return _config_port()
1229
+ except Exception as e:
1230
+ # 락 타임아웃/다중 인스턴스 등은 CLI 를 죽이지 말고 기존 포트 할당으로 폴백
1231
+ whatap_print('WHATAP: live agent check skipped, falling back to port file: %s' % e)
1232
+ return _config_port()
1233
+
1234
+
1235
+ def _config_port():
1089
1236
  # force_net_udp_port가 설정된 경우 lock 파일 무시하고 해당 포트 강제 사용
1090
1237
  force_port_str = preview_whatap_conf("force_net_udp_port")
1091
1238
  if force_port_str and force_port_str not in ('false', 'False'):
whatap/build.py CHANGED
@@ -1,4 +1,4 @@
1
1
  app = 'Python'
2
2
  name = 'whatap-python'
3
- version = '2.2.0'
4
- release_date = '20260901'
3
+ version = '2.2.1'
4
+ release_date = '20260929'
@@ -39,7 +39,7 @@ Configuration = {
39
39
  # 해당 메서드 실행이 워크플로우가 되고 내부 LLM 호출은 스텝. workflow_plugin 참조.
40
40
  "workflow_method_patterns": "",
41
41
  # 트랜잭션(서비스) 진입점으로 자동 래핑할 메서드 패턴 (module:Class.method, 콤마 구분).
42
- # 해당 메서드 호출이 하나의 트랜잭션이 된다(= @method_profiling 의 conf 버전).
42
+ # 해당 메서드 호출이 하나의 트랜잭션이 된다(구 @method_profiling 데코레이터 대체).
43
43
  # 프레임워크 자동계측이 안 되는 진입점용. hook_service_patterns 모듈 참조.
44
44
  "hook_service_patterns": "",
45
45
 
@@ -144,6 +144,26 @@ Configuration = {
144
144
  # APM Go Agent 가 떠 있는 배포(apm_enabled=true)에서만 의미가 있다.
145
145
  "llm_apm_txn_enabled": False,
146
146
 
147
+ # ── LLM 계측 킬스위치 (문제 발생 시 에이전트 다운그레이드 없이 되돌릴 레버) ──
148
+ #
149
+ # **여기 등록돼 있어야 whatap.conf 의 false 가 먹는다.** ``Configure.setProperty`` 는
150
+ # 이 표에 bool 기본값이 있는 키만 문자열 'false' → False 로 강제 변환한다. 미등록 키는
151
+ # ``'false'`` 문자열로 남고 ``bool('false') == True`` 라 게이트가 계속 열려 있다
152
+ # (실측으로 확인: 등록 키는 False, 미등록 키는 'false'/truthy).
153
+ #
154
+ # llm_workflow_enabled : 워크플로우/에이전트 경계 계측 전체 (구 키 llm_trajectory_enabled
155
+ # 와 AND — 어느 쪽을 false 로 줘도 꺼진다)
156
+ # mtrace_kanban_enabled: hermes 칸반 카드 경계 전파
157
+ # llm_thread_pool_propagation_enabled:
158
+ # Thread/ThreadPoolExecutor 컨텍스트 전파 몽키패치
159
+ # llm_httpc_step_enabled: 워크플로우 안의 httpc 스텝을 **전부** LLM 채널에도 싣는다
160
+ # (APM 쪽은 무관하게 그대로). false 면 LLM API httpc 만.
161
+ "llm_workflow_enabled": True,
162
+ "llm_trajectory_enabled": True,
163
+ "mtrace_kanban_enabled": True,
164
+ "llm_thread_pool_propagation_enabled": True,
165
+ "llm_httpc_step_enabled": True,
166
+
147
167
  # 프로세스 종료 시 송출 큐를 비우는 최대 대기(초, 0 이면 대기 안 함). sender 스레드는
148
168
  # daemon 이라 exit 시 즉사하므로, 원샷 CLI 에이전트처럼 단명하는 프로세스는 마지막
149
169
  # TX_END 가 큐에 남은 채 사라질 수 있다. standalone 모듈은 자체 atexit 로 이걸
@@ -369,6 +389,50 @@ Configuration = {
369
389
  # (llm_actx_bands)만 붙는다.
370
390
  "llm_traj_actx_enabled": True,
371
391
 
392
+ # ── LLM 트래젝토리 제어 (ParamPack 905) ──
393
+ # 실행 중인 트래젝토리(mtid 가족)를 서버에서 중지시킨다. 스코프는 트랜잭션 되감기가
394
+ # 아니라 **진행 중 LLM 호출 끊기 + 이후 LLM 호출 차단** 이다 — 앱은 평범한 연결오류를
395
+ # 받고 스스로 응답한다(되감기는 gthread 에서 클라이언트 60초 행을 만들었다).
396
+ #
397
+ # llm_control_enabled: 기능 킬스위치. false 면 표시도 끊기도 하지 않고 905 에
398
+ # 204(지원하지 않음)로 답한다 — 400(대상 없음)으로 답하면 "왜 안 되지" 를 데이터
399
+ # 문제로 오독하게 된다.
400
+ # llm_control_async_cut_enabled: async 호출을 우리 태스크로 감쌀지. 코루틴은 소켓이
401
+ # 스택에 없어(sync 는 있다) 취소 핸들을 **미리** 만들어 두는 것이 유일한 수단이라,
402
+ # 중지 요청이 없어도 모든 async LLM 호출에 적용된다. 의심스러운 환경(비-asyncio
403
+ # 런타임 등)에서 이것만 끄면 async 는 "이후 호출 차단" 만 남는다(진행 중 호출은
404
+ # 그대로 끝난다). sync 경로는 이 값과 무관하게 동작한다.
405
+ # llm_control_stop_transaction_enabled: 중지 스코프. true(기본)면 진행 중 LLM 호출을
406
+ # 끊은 뒤 **그 트랜잭션을 실행 중인 주체까지 끝낸다**(async=태스크 취소,
407
+ # sync=스레드에 BaseException 주입). false 면 LLM 호출만 끊고 앱은 계속 돈다
408
+ # (이후 LLM 호출은 전부 실패하므로 LLM 작업량은 0).
409
+ # **끄는 것이 나은 경우: gunicorn gthread 워커.** 이전 PoC 실측에서 그 조합만 응답
410
+ # 없이 클라이언트가 60초 대기했다(sync/uvicorn 은 그 요청 500, 워커·옆 요청 무사).
411
+ "llm_control_enabled": True,
412
+ # llm_control_stop_thread_enabled: sync 스레드에 **예외 주입**까지 할 것인가(기본 off).
413
+ # 다른 종료 수단과 안전성이 다르다 — asyncio 취소는 await 지점, greenlet kill 은
414
+ # switch 지점, 협조적 체크포인트는 우리 프레임에서 발동하지만, 스레드 주입만
415
+ # **임의 바이트코드**에서 터진다(실측: 수동 락 구간에 걸리면 락이 영구 점유되고,
416
+ # 블로킹 시스템콜 중에는 아예 발동하지 않는다). 끄더라도 sync 앱은 협조적
417
+ # 체크포인트로 멈춘다 — 계측 경계(LLM 호출/HTTP/워크플로우)를 지나면 끝난다.
418
+ # 비계측 블로킹에 갇힌 스레드까지 강제 종료해야 하는 운영자만 켠다.
419
+ "llm_control_stop_transaction_enabled": True,
420
+ "llm_control_stop_thread_enabled": False,
421
+ "llm_control_async_cut_enabled": True,
422
+ # llm_blocked_users: **사용자 단위 차단**(콤마 구분 user_id 목록). 905 가 서버에서
423
+ # mtid 하나를 지목하는 일회성 사건이라면, 이쪽은 **설정이 곧 상태**다 — 여기 올라
424
+ # 있는 동안 그 사용자의 LLM 호출은 계속 막히고, 빼면 즉시 풀린다(설정 핫리로드
425
+ # 주기 30초). 집행 지점은 905 와 같다: 새 호출은 가드에서, SDK/앱 재시도는 httpc
426
+ # 체크포인트에서, 진행 중 호출은 1초 틱 스윕에서 끊는다.
427
+ # 대상 값은 ``ctx.login_id`` — set_user_id()/llm_user_header/llm_user_meta_key/
428
+ # hermes 4경로로 채워지는 그 user_id 이고, 로그싱크 팩의 user_id 태그와 같은 값이라
429
+ # 화면에서 본 값을 그대로 옮겨 적으면 된다. 대소문자 무시, 미식별('0')은 대상 아님.
430
+ # llm_blocked_users=u-1024,abuse@example.com
431
+ # **해제는 줄을 지우는 게 아니라 값을 비우는 것**(`llm_blocked_users=`)이다. 핫리로드
432
+ # (`Configure.load`)는 파일에 있는 키만 다시 세팅하고 사라진 키를 기본값으로 되돌리지
433
+ # 않는다 — 줄을 지우면 재기동 전까지 마지막 값이 그대로 남아 계속 차단된다.
434
+ "llm_blocked_users": "",
435
+
372
436
  # ── LLM Evaluation ──
373
437
  # 평가 파이프라인 마스터 토글. false면 enqueue/worker가 동작하지 않음.
374
438
  "llm_eval_enabled": False,
@@ -417,6 +481,13 @@ Configuration = {
417
481
  # 평가자만 적용되고 이 전역 목록은 적용되지 않는다.
418
482
  "llm_eval_evaluators": "",
419
483
 
484
+ # 정형 평가자(pii_leak / url_scan)의 스캔 범위. 기본은 output_text(모델 응답)만 —
485
+ # "모델이 응답에 PII/URL 을 흘렸나(leak)" 관점이라 사용자가 프롬프트에 넣은 값은
486
+ # 대상이 아니다. true 로 두면 같은 LLM 호출을 input+output 한 덩어리로 보고
487
+ # input_text 도 함께 스캔 — 입력에만 있어도(예: 프롬프트에 붙은 카드번호/URL) 검출된다
488
+ # (프롬프트-사이드 DLP). LLM judge(combined_judge 등)에는 영향 없음.
489
+ "llm_eval_scan_input": False,
490
+
420
491
  # ── conf 기반 default judge (앱 코드 0) ──
421
492
  # llm-as-judge 평가자(combined_judge 등)는 평가용 LLM 호출에 자체 자격증명이 필요하다.
422
493
  # judge 해석 순서: ① evaluator(judge_fn=) → ② set_default_judge_fn() → ③ 계측이
@@ -28,6 +28,16 @@ class LlmStatTask(Thread):
28
28
  while True:
29
29
  current_time = time.time()
30
30
  time.sleep(1)
31
+ # 차단 사용자(llm_blocked_users)의 **진행 중** LLM 호출 끊기. 가드/체크포인트는
32
+ # "다음 호출" 만 막으므로, 설정으로 차단을 건 순간 이미 돌고 있는 스트리밍은
33
+ # 여기서 끊어야 운영자가 본 대로 동작한다. 목록이 비면(기본) getattr 한 번에
34
+ # 끝나고, 모집단도 진행 중 호출 레지스트리(0~수 개)라 틱 비용이 없다.
35
+ # 통계와 무관한 일이지만 LLM 모드에서 도는 주기 스레드가 여기뿐이다.
36
+ try:
37
+ from whatap.llm.control import enforce_blocked_users
38
+ enforce_blocked_users()
39
+ except Exception as e:
40
+ logging.debug(e, extra={'id': 'WA186'}, exc_info=True)
31
41
  for stat in self.stats:
32
42
  last_executed_time = self.last_executed[stat.name()]
33
43
  interval = stat.interval()