whatap-python 2.2.2__py3-none-any.whl → 2.3.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.
@@ -1,8 +1,8 @@
1
- """provider 별 judge_fn 팩토리 (OpenAI / Anthropic).
1
+ """provider 별 judge_fn 팩토리 (OpenAI / Anthropic / Bedrock).
2
2
 
3
3
  M-4: ``llm_judge.py`` 에서 분리 — evaluator 레이어에서 유일하게 구체 LLM SDK 를 아는 곳.
4
- SDK(``openai`` / ``anthropic``) import 는 judge_fn 이 **처음 호출될 때** lazy 로 일어난다
5
- (모듈 로드 시점 아님) — 그 SDK 가 설치 안 된 환경에서도 이 모듈 import 는 안전하다.
4
+ SDK(``openai`` / ``anthropic`` / ``botocore``) import 는 judge_fn 이 **처음 호출될 때** lazy
5
+ 로 일어난다 (모듈 로드 시점 아님) — 그 SDK 가 설치 안 된 환경에서도 이 모듈 import 는 안전하다.
6
6
 
7
7
  judge 는 항상 **자기 소유의 client** 로 동작한다 — 사용자 요청 client 를 빌리지 않으므로
8
8
  요청 수명/event loop 상태와 무관하다. (요청 스코프 client 는 평가가 비동기로 도는 시점에
@@ -10,11 +10,29 @@ judge 는 항상 **자기 소유의 client** 로 동작한다 — 사용자 요
10
10
 
11
11
  하위호환: ``make_openai_judge`` / ``make_anthropic_judge`` 는 ``llm_judge`` 가 re-export
12
12
  하므로 기존 import 경로(``from ...llm_judge import make_openai_judge``)가 그대로 동작한다.
13
+
14
+ 최신 모델은 judge 가 보내던 옵션 일부를 거절한다(temperature · 출력 한도). judge 는 거절당한
15
+ 옵션을 한 번 빼거나 바꿔 다시 보내고, 그 뒤로는 처음부터 그 모양으로 보낸다.
16
+
17
+ 출력 한도는 빼면 안 되는 곳에만 보낸다. OpenAI 는 선택 항목이라 보내지 않는다(모델 기본값).
18
+ Anthropic 은 API 가 요구한다. Bedrock 은 요청마다 이 한도만큼 계정 할당량(TPM)을 먼저 잡는데,
19
+ 빼면 모델 최대치를 잡아 앱 호출이 throttle 될 수 있다. 입력은 자르지 않는다.
13
20
  """
21
+ import re
22
+
23
+ # Anthropic · Bedrock judge 출력 한도. Claude 5 계열은 thinking 을 기본으로 켜고 thinking 토큰도
24
+ # 이 한도에 들어가 2048 이면 판정이 잘린다. 서비스 중인 Claude 모델이 모두 받는 값이고(최소 64K),
25
+ # 비스트리밍 요청이 SDK 의 긴 요청 검사에 걸리지 않는 선이다.
26
+ _JUDGE_MAX_TOKENS = 16000
27
+ # 출력 한도가 그보다 작은 Bedrock 모델(Llama · Titan · Nova 등)이 거절하면 낮출 값 — 예전 기본값.
28
+ _SMALL_MAX_TOKENS = 2048
29
+ # Bedrock 이 출력 한도 초과를 알리는 메시지의 칸 이름(maxTokens · max_tokens · max_new_tokens ·
30
+ # max_gen_len · "maximum tokens").
31
+ _MAX_TOKENS_REJECTION = re.compile(r'max(?:imum)?[ _]?(?:new_)?tokens?|max_gen_len')
14
32
 
15
33
 
16
34
  def make_openai_judge(client=None, model='gpt-4o-mini', api_key=None, base_url=None,
17
- temperature=0.0, max_tokens=2048, timeout=None):
35
+ temperature=0.0, max_tokens=None, timeout=None):
18
36
  """OpenAI 호환 API 를 사용하는 judge_fn 을 생성한다.
19
37
 
20
38
  judge 는 **자기 소유의 client** 로 동작한다 — ``client`` 를 주면 그대로, 안 주면 첫
@@ -24,10 +42,18 @@ def make_openai_judge(client=None, model='gpt-4o-mini', api_key=None, base_url=N
24
42
  :param client: 기존 ``openai.OpenAI`` 인스턴스 (선택).
25
43
  :param model: 평가용 모델.
26
44
  :param base_url: OpenAI 호환 엔드포인트 (client 미지정 시).
45
+ :param temperature: 판정을 일정하게 하려는 샘플링 온도(기본 0.0). 기본값만 받는 reasoning
46
+ 모델(o 시리즈 · gpt-5 등)이면 한 번 거절당한 뒤로는 빼고 보낸다. ``None`` 이면 처음부터
47
+ 보내지 않는다.
48
+ :param max_tokens: 출력 한도. 기본 ``None`` 은 보내지 않고 모델 기본값에 맡긴다 — 한도가 작은
49
+ 자체 호스팅 모델도, ``max_tokens`` 를 받지 않는 reasoning 모델도 거절할 일이 없다. 값을
50
+ 주면 ``max_tokens`` 로 보내고, reasoning 모델이 거절하면 같은 값을
51
+ ``max_completion_tokens`` 로 보낸다.
27
52
  :param timeout: judge 1회 호출 최대 대기 초. client 를 새로 만들 때만 적용
28
53
  (None 이면 SDK 기본). worker 무한 블록 방지.
29
54
  """
30
55
  _client_ref = [client]
56
+ state = {'no_temperature': temperature is None, 'completion_tokens': False}
31
57
 
32
58
  def judge_fn(system, user):
33
59
  c = _client_ref[0]
@@ -42,28 +68,75 @@ def make_openai_judge(client=None, model='gpt-4o-mini', api_key=None, base_url=N
42
68
  client_kwargs['timeout'] = timeout
43
69
  c = openai.OpenAI(**client_kwargs)
44
70
  _client_ref[0] = c
45
- resp = c.chat.completions.create(
46
- model=model,
47
- messages=[
48
- {'role': 'system', 'content': system},
49
- {'role': 'user', 'content': user},
50
- ],
51
- temperature=temperature,
52
- max_tokens=max_tokens,
53
- )
54
- return resp.choices[0].message.content or ''
71
+ while True:
72
+ request = _chat_request(model, system, user, max_tokens, temperature, state)
73
+ try:
74
+ resp = c.chat.completions.create(**request)
75
+ except Exception as e:
76
+ # 모델이 거절한 옵션을 바꿔 다시 — 옵션마다 한 번뿐이라 루프는 끝난다.
77
+ rejected = _openai_rejected_option(e, request)
78
+ if rejected is None:
79
+ raise
80
+ state[rejected] = True
81
+ continue
82
+ return resp.choices[0].message.content or ''
55
83
  return judge_fn
56
84
 
57
85
 
58
- def make_anthropic_judge(client=None, model='claude-3-5-haiku-latest', api_key=None,
59
- max_tokens=2048, temperature=0.0, timeout=None):
86
+ def _chat_request(model, system, user, max_tokens, temperature, state):
87
+ """Chat Completions 요청 — 앞서 거절당한 옵션은 빼거나 reasoning 모델 모양으로 바꾼다."""
88
+ request = {
89
+ 'model': model,
90
+ 'messages': [
91
+ {'role': 'system', 'content': system},
92
+ {'role': 'user', 'content': user},
93
+ ],
94
+ }
95
+ if max_tokens is not None:
96
+ # reasoning 모델은 같은 한도를 max_completion_tokens 로 받는다.
97
+ key = 'max_completion_tokens' if state['completion_tokens'] else 'max_tokens'
98
+ request[key] = max_tokens
99
+ if not state['no_temperature']:
100
+ request['temperature'] = temperature
101
+ return request
102
+
103
+
104
+ def _openai_rejected_option(err, request):
105
+ """reasoning 모델의 거절 → 다음부터 바꿀 ``state`` 키. 그런 거절이 아니면 None.
106
+
107
+ openai ``BadRequestError``(400) 의 ``param`` 과 메시지가 거절한 칸을 짚는다. 이번 요청에 넣은
108
+ 옵션만 짚으므로 같은 옵션을 두 번 바꾸며 돌지 않는다.
109
+ """
110
+ if getattr(err, 'status_code', None) != 400:
111
+ return None
112
+ message = ('%s %s' % (getattr(err, 'param', None) or '', err)).lower()
113
+ if 'temperature' in message and 'temperature' in request:
114
+ return 'no_temperature'
115
+ if 'max_completion_tokens' in message and 'max_tokens' in request:
116
+ return 'completion_tokens'
117
+ return None
118
+
119
+
120
+ def make_anthropic_judge(client=None, model='claude-haiku-5-5', api_key=None,
121
+ max_tokens=_JUDGE_MAX_TOKENS, temperature=0.0, timeout=None):
60
122
  """Anthropic 을 사용하는 judge_fn 을 생성한다.
61
123
 
62
124
  judge 는 **자기 소유의 client** 로 동작한다 — ``client`` 미지정 시 첫 호출에
63
125
  ``anthropic.Anthropic(...)`` 를 만들어(env ``ANTHROPIC_API_KEY`` 또는 ``api_key``)
64
126
  재사용. ``timeout`` 은 client 를 새로 만들 때만 적용 (worker 무한 블록 방지).
127
+
128
+ ``temperature`` 는 SDK 인자가 아니라 ``extra_body`` 로 싣는다 — anthropic SDK 1.x 는
129
+ ``messages.create()`` 의 sampling 인자를 없앴다(넘기면 TypeError). 받지 않는 모델(Opus 4.7
130
+ 이후는 값과 무관하게, Sonnet 5·5.5 · Haiku 5.5 는 기본값이 아니면)이나 모든 모델이 거절하는
131
+ API 버전 2026-10-21(SDK 2.x 기본)이면 한 번 거절당한 뒤로는 빼고 보낸다. ``None`` 이면
132
+ 처음부터 보내지 않는다.
133
+
134
+ ``max_tokens`` 는 Messages API 가 요구해서 뺄 수 없다. 넉넉히 두는 건 Claude 5 계열이
135
+ thinking 을 기본으로 켜고 thinking 토큰도 이 한도에 들어가서다 — 작으면 판정이 잘린다.
136
+ 서비스 중인 모델은 모두 이 값을 받고, OTPM 할당량은 실제 출력만 세므로 크게 둬도 손해가 없다.
65
137
  """
66
138
  _client_ref = [client]
139
+ state = {'no_temperature': temperature is None}
67
140
 
68
141
  def judge_fn(system, user):
69
142
  c = _client_ref[0]
@@ -76,15 +149,175 @@ def make_anthropic_judge(client=None, model='claude-3-5-haiku-latest', api_key=N
76
149
  client_kwargs['timeout'] = timeout
77
150
  c = anthropic.Anthropic(**client_kwargs)
78
151
  _client_ref[0] = c
79
- resp = c.messages.create(
80
- model=model,
81
- system=system,
82
- messages=[{'role': 'user', 'content': user}],
83
- max_tokens=max_tokens,
84
- temperature=temperature,
85
- )
86
- for block in resp.content:
87
- if getattr(block, 'type', None) == 'text':
88
- return block.text or ''
89
- return ''
152
+ request = {
153
+ 'model': model,
154
+ 'system': system,
155
+ 'messages': [{'role': 'user', 'content': user}],
156
+ 'max_tokens': max_tokens,
157
+ }
158
+ if not state['no_temperature']:
159
+ try:
160
+ return _anthropic_text(c.messages.create(
161
+ extra_body={'temperature': temperature}, **request))
162
+ except Exception as e:
163
+ if not _rejects_temperature(e):
164
+ raise
165
+ state['no_temperature'] = True
166
+ return _anthropic_text(c.messages.create(**request))
90
167
  return judge_fn
168
+
169
+
170
+ def _rejects_temperature(err):
171
+ """temperature 를 받지 않는 모델·API 버전의 거절인가 — anthropic ``BadRequestError``(400)."""
172
+ return getattr(err, 'status_code', None) == 400 and 'temperature' in str(err).lower()
173
+
174
+
175
+ def _anthropic_text(resp):
176
+ """첫 text 블록 — thinking 블록 등은 건너뛴다."""
177
+ for block in resp.content:
178
+ if getattr(block, 'type', None) == 'text':
179
+ return block.text or ''
180
+ return ''
181
+
182
+
183
+ def make_bedrock_judge(client=None, model=None, region_name=None, endpoint_url=None,
184
+ profile_name=None, credentials=None,
185
+ max_tokens=_JUDGE_MAX_TOKENS, temperature=0.0, timeout=None):
186
+ """AWS Bedrock Runtime 의 Converse API 를 사용하는 judge_fn 을 생성한다.
187
+
188
+ Converse 는 모델과 무관한 공통 스키마라 Claude·Nova·Llama·Mistral 등 Bedrock 텍스트
189
+ 모델을 한 경로로 부른다. ``model`` 은 modelId · 추론 프로파일 ID(``us.anthropic...``)
190
+ · ARN 모두 된다. **기본 모델이 없다** — 계정·리전마다 열린 모델(모델 액세스·추론
191
+ 프로파일)이 달라 '작은 기본 모델'을 가정할 수 없다.
192
+
193
+ judge 는 **자기 소유의 client** 로 동작한다 — ``client``(boto3 ``bedrock-runtime``)를
194
+ 주면 그대로, 안 주면 첫 호출에 botocore 로 만든다(boto3 불필요).
195
+
196
+ :param region_name: AWS 리전. 없으면 AWS 기본 해석(AWS_DEFAULT_REGION, ~/.aws/config …
197
+ — botocore 는 AWS_REGION 을 읽지 않는다).
198
+ :param endpoint_url: Bedrock Runtime 엔드포인트(VPC 엔드포인트 등). 없으면 리전 기본값.
199
+ :param profile_name: ~/.aws 프로파일 이름. ``credentials`` 가 있으면 그쪽이 우선.
200
+ :param credentials: botocore 자격증명 객체(``boto3.Session().get_credentials()`` 등).
201
+ 호출마다 현재 값을 꺼내므로 갱신형(assume-role·IMDS·SSO)도 만료되지 않고, 값이
202
+ 바뀌면 client 를 새로 만든다. 없으면 AWS 기본 자격증명 체인.
203
+ :param timeout: 연결·읽기 타임아웃(초). client 를 새로 만들 때만 적용
204
+ (None 이면 botocore 기본). worker 무한 블록 방지.
205
+ :param max_tokens: 출력 한도(기본 16000) — Claude 5 계열은 thinking 토큰도 여기에 들어간다.
206
+ 한도가 이보다 작은 모델(Llama · Titan · Nova 등)이 거절하면 한 번 뒤로는 2048 로 보낸다.
207
+ 빼지 않는 건 Bedrock 이 요청마다 이 값만큼 계정 할당량(TPM)을 먼저 잡아서다 — 빼면 모델
208
+ 최대치(Claude 는 64K~128K)를 잡아 같은 모델을 쓰는 앱 호출이 throttle 될 수 있다.
209
+ :param temperature: 판정을 일정하게 하려는 샘플링 온도(기본 0.0). 받지 않는 모델(Claude
210
+ Opus 4.7 이후는 값과 무관하게, Sonnet 5·5.5 · Haiku 5.5 는 기본값이 아니면 거절)이면
211
+ 한 번 거절당한 뒤로는 빼고 보낸다. ``None`` 이면 처음부터 보내지 않는다.
212
+
213
+ system 블록을 받지 않는 모델(Titan Text·Mistral Instruct 등)이면 한 번 거절당한 뒤로는
214
+ system 을 user 앞에 붙여 보낸다.
215
+ """
216
+ if not model:
217
+ raise ValueError('make_bedrock_judge: model is required '
218
+ '(Bedrock modelId, inference profile ID or ARN)')
219
+ state = {'client': client, 'identity': None, 'no_system': False,
220
+ 'no_temperature': temperature is None, 'small_max_tokens': False}
221
+
222
+ def _client():
223
+ if client is not None:
224
+ return client
225
+ frozen = credentials.get_frozen_credentials() if credentials is not None else None
226
+ identity = None
227
+ if frozen is not None:
228
+ identity = (frozen.access_key, frozen.secret_key, frozen.token)
229
+ if state['client'] is None or identity != state['identity']:
230
+ state['client'] = _new_bedrock_client(region_name, endpoint_url, profile_name,
231
+ frozen, timeout)
232
+ state['identity'] = identity
233
+ return state['client']
234
+
235
+ def judge_fn(system, user):
236
+ c = _client()
237
+ while True:
238
+ request = _converse_request(model, system, user, max_tokens, temperature, state)
239
+ try:
240
+ return _converse_text(c.converse(**request))
241
+ except Exception as e:
242
+ # 모델이 거절한 옵션을 빼고 다시 — 옵션마다 한 번뿐이라 루프는 끝난다.
243
+ rejected = _rejected_option(e, request)
244
+ if rejected is None:
245
+ raise
246
+ state[rejected] = True
247
+ return judge_fn
248
+
249
+
250
+ def _new_bedrock_client(region_name, endpoint_url, profile_name, frozen, timeout):
251
+ """judge 전용 ``bedrock-runtime`` client — botocore 만으로 만든다(boto3 불필요)."""
252
+ import botocore.session
253
+ from botocore.config import Config
254
+
255
+ session = botocore.session.Session(profile=profile_name or None)
256
+ # 재시도는 openai/anthropic SDK 기본(max_retries=2)과 맞춘다 — botocore 기본(4회)에
257
+ # 타임아웃이 곱해지면 워커 하나가 분 단위로 묶인다.
258
+ config = {'retries': {'max_attempts': 2}}
259
+ if timeout:
260
+ config['connect_timeout'] = timeout
261
+ config['read_timeout'] = timeout
262
+ kwargs = {'config': Config(**config)}
263
+ if region_name:
264
+ kwargs['region_name'] = region_name
265
+ if endpoint_url:
266
+ kwargs['endpoint_url'] = endpoint_url
267
+ if frozen is not None:
268
+ kwargs['aws_access_key_id'] = frozen.access_key
269
+ kwargs['aws_secret_access_key'] = frozen.secret_key
270
+ kwargs['aws_session_token'] = frozen.token
271
+ return session.create_client('bedrock-runtime', **kwargs)
272
+
273
+
274
+ def _converse_request(model, system, user, max_tokens, temperature, state):
275
+ """Converse 요청 — 앞서 거절당한 옵션(``state`` 의 no_system / no_temperature /
276
+ small_max_tokens)은 빼거나 낮춘다."""
277
+ if state['small_max_tokens']:
278
+ max_tokens = min(max_tokens, _SMALL_MAX_TOKENS)
279
+ config = {'maxTokens': max_tokens}
280
+ if not state['no_temperature']:
281
+ config['temperature'] = temperature
282
+ request = {'modelId': model, 'inferenceConfig': config}
283
+ if system and not state['no_system']:
284
+ request['system'] = [{'text': system}]
285
+ text = user
286
+ else:
287
+ text = '%s\n\n%s' % (system, user) if system else user
288
+ request['messages'] = _bedrock_user_message(text)
289
+ return request
290
+
291
+
292
+ def _bedrock_user_message(text):
293
+ return [{'role': 'user', 'content': [{'text': text}]}]
294
+
295
+
296
+ def _rejected_option(err, request):
297
+ """모델이 거절한 요청 옵션 → 다음부터 뺄 ``state`` 키. 그런 거절이 아니면 None.
298
+
299
+ botocore ``ClientError``(ValidationException) 메시지가 거절한 칸을 이름으로 짚는다. 이번
300
+ 요청에 넣은 옵션만 짚으므로 같은 옵션을 두 번 빼며 돌지 않는다.
301
+ """
302
+ try:
303
+ error = err.response.get('Error') or {}
304
+ except Exception:
305
+ return None
306
+ if error.get('Code') != 'ValidationException':
307
+ return None
308
+ message = str(error.get('Message') or '').lower()
309
+ config = request['inferenceConfig']
310
+ if 'temperature' in message and 'temperature' in config:
311
+ return 'no_temperature'
312
+ if _MAX_TOKENS_REJECTION.search(message) and config['maxTokens'] > _SMALL_MAX_TOKENS:
313
+ return 'small_max_tokens'
314
+ if 'system' in message and 'system' in request:
315
+ return 'no_system'
316
+ return None
317
+
318
+
319
+ def _converse_text(resp):
320
+ """Converse 응답의 텍스트 블록을 이어 붙인다(reasoningContent 등 다른 블록은 건너뛴다)."""
321
+ message = ((resp or {}).get('output') or {}).get('message') or {}
322
+ return ''.join(block.get('text') or '' for block in message.get('content') or []
323
+ if isinstance(block, dict))
@@ -2,7 +2,7 @@
2
2
 
3
3
  LLM judge 평가자는 자체적으로 다른 LLM 을 호출해 평가를 수행한다.
4
4
  - 비용이 비싸므로 보통 샘플링과 함께 사용한다 (whatap.conf 의 llm_eval_sample_rate).
5
- - 워커 스레드의 재귀 가드 (LlmEvaluatorTask._safe_run_one 의 thread-local) 가
5
+ - 워커의 재귀 가드 (``hooks.enter_judge_call`` 의 judge 호출 표시) 가
6
6
  judge 의 LLM 호출이 다시 평가 큐에 들어가는 것을 자동 차단한다.
7
7
 
8
8
  사용:
@@ -14,20 +14,34 @@ LLM judge 평가자는 자체적으로 다른 LLM 을 호출해 평가를 수행
14
14
 
15
15
  M-4 분리: 이 파일은 이제 베이스 클래스 + default judge 레지스트리 + 에러/타임아웃 헬퍼만
16
16
  담는다. 아래 두 관심사는 별도 모듈로 분리했고, 하위호환을 위해 여기서 re-export 한다:
17
- - provider 별 judge_fn 팩토리 → ``judge_factories`` (make_openai_judge/make_anthropic_judge)
17
+ - provider 별 judge_fn 팩토리 → ``judge_factories``
18
+ (make_openai_judge/make_anthropic_judge/make_bedrock_judge)
18
19
  - judge 응답 JSON 추출/복구 유틸 → ``_json_repair`` (parse_json_response 등)
19
20
  """
21
+ import re
22
+
20
23
  from whatap import logging
21
24
  from whatap.llm.evaluators.base import BaseEvaluator, EvaluatorResult
22
25
 
23
26
  # ── 하위호환 re-export (기존 import 경로 유지: from ...llm_judge import make_openai_judge 등) ──
24
27
  from whatap.llm.evaluators.builtins.judge_factories import ( # noqa: F401
25
- make_openai_judge, make_anthropic_judge,
28
+ make_openai_judge, make_anthropic_judge, make_bedrock_judge,
26
29
  )
27
30
  from whatap.llm.evaluators.builtins._json_repair import ( # noqa: F401
28
31
  parse_json_response, _salvage_truncated_object, _JSON_BLOCK,
29
32
  )
30
33
 
34
+ # Bedrock Runtime 호스트 → 리전. 리전 기본·FIPS·VPC 엔드포인트 모두:
35
+ # bedrock-runtime.ap-northeast-1.amazonaws.com
36
+ # bedrock-runtime-fips.us-gov-west-1.amazonaws.com
37
+ # vpce-0abc123.bedrock-runtime.us-east-1.vpce.amazonaws.com
38
+ _BEDROCK_HOST = re.compile(
39
+ r'bedrock-runtime(?:-fips)?\.([a-z]{2}(?:-[a-z]+)+-\d+)\.(?:vpce\.)?amazonaws\.com')
40
+
41
+ # 자동 해석한 Bedrock judge 캐시 상한 — 키에 자격증명 객체가 들어가서, 요청마다
42
+ # boto3.Session 을 새로 만드는 앱이면 끝없이 늘 수 있다.
43
+ _BEDROCK_CACHE_MAX = 32
44
+
31
45
 
32
46
  # 전역 default judge. set_default_judge_fn() 또는 conf.llm_eval_judge_* 부트스트랩이 등록.
33
47
  # judge 해석 순서 ②(우선순위 2) 자리 — ① evaluator judge_fn 다음, ③ 자격증명 스냅샷 앞.
@@ -107,7 +121,9 @@ class LLMJudgeBase(BaseEvaluator):
107
121
  2) 전역 default — ``set_default_judge_fn()`` 또는 ``conf.llm_eval_judge_*`` override.
108
122
  3) 계측이 캡처한 **앱 client 자격증명 스냅샷(base_url/api_key)** 으로 자기 client 생성
109
123
  — 앱과 같은 엔드포인트/키로 평가(설정 0). **기본 zero-config 경로.**
124
+ Bedrock 호출이면 앱과 같은 리전·엔드포인트·AWS 자격증명으로 Bedrock Runtime(Converse).
110
125
  4) ctx.provider 매칭 후 **환경변수 키**(OPENAI_API_KEY/ANTHROPIC_API_KEY)로 자기 client.
126
+ Bedrock 호스트면 호스트의 리전 + AWS 기본 자격증명 체인.
111
127
 
112
128
  OAuth 라 평문 key 가 없거나 엔드포인트가 chat.completions 를 안 서빙하면 (2) 로 지정.
113
129
  """
@@ -124,7 +140,7 @@ class LLMJudgeBase(BaseEvaluator):
124
140
 
125
141
  # 기본 모델 (provider 별).
126
142
  _DEFAULT_OPENAI_MODEL = 'gpt-4o-mini'
127
- _DEFAULT_ANTHROPIC_MODEL = 'claude-3-5-haiku-latest'
143
+ _DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-5-5'
128
144
 
129
145
  def __init__(self, judge_fn=None, model=None):
130
146
  if judge_fn is not None and not callable(judge_fn):
@@ -150,7 +166,9 @@ class LLMJudgeBase(BaseEvaluator):
150
166
  ② set_default_judge_fn() 전역 default **또는 conf.llm_eval_judge_* override**
151
167
  ③ 계측이 캡처한 **앱 client 자격증명 스냅샷(base_url/api_key)** 으로 자기 client 생성
152
168
  — 앱과 같은 엔드포인트/키로 평가(설정 0). 인스턴스가 아니라 '값'만 재사용.
153
- ④ ctx.provider 매칭(openai.com/azure/anthropic) 후 **환경변수 키로** 자기 client 생성.
169
+ Bedrock(``judge_kind='bedrock'``)은 API 키가 아니라 AWS 자격증명이라 따로 간다.
170
+ ④ ctx.provider 매칭(openai.com/azure/anthropic/bedrock-runtime) 후 **환경변수 키로**
171
+ (Bedrock 은 AWS 기본 자격증명 체인으로) 자기 client 생성.
154
172
  그 외 provider + 스냅샷도 없으면 None → no_judge_configured.
155
173
 
156
174
  ③ 이 기본 zero-config 경로다 — self-host/프록시도 스냅샷된 base_url 로 그대로 동작.
@@ -166,6 +184,10 @@ class LLMJudgeBase(BaseEvaluator):
166
184
 
167
185
  timeout = _judge_timeout_sec()
168
186
 
187
+ # ③-a Bedrock — 서명이 API 키가 아니라 AWS(SigV4)라 아래 키 스냅샷과 갈린다.
188
+ if getattr(ctx, 'judge_kind', None) == 'bedrock':
189
+ return self._resolve_bedrock_judge(ctx, timeout)
190
+
169
191
  # ③ 계측이 스냅샷한 앱 client 자격증명(문자열)으로 자기 durable client 생성.
170
192
  # 인스턴스가 아니라 base_url/api_key '값'만 재사용 → client 수명과 무관.
171
193
  snap_key = getattr(ctx, 'judge_api_key', None)
@@ -197,6 +219,8 @@ class LLMJudgeBase(BaseEvaluator):
197
219
  key = 'anthropic'
198
220
  elif 'openai.com' in provider or 'azure.com' in provider:
199
221
  key = 'openai'
222
+ elif _BEDROCK_HOST.search(provider):
223
+ return self._resolve_bedrock_judge(ctx, timeout)
200
224
  else:
201
225
  return None
202
226
 
@@ -214,6 +238,45 @@ class LLMJudgeBase(BaseEvaluator):
214
238
  self._auto_cache[key] = judge_fn
215
239
  return judge_fn
216
240
 
241
+ def _resolve_bedrock_judge(self, ctx, timeout):
242
+ """Bedrock Runtime judge — 앱과 같은 리전·엔드포인트·자격증명으로, 같은 모델을 Converse 로.
243
+
244
+ 모델 기본값이 없다(계정·리전마다 열린 모델이 다르다). 앱이 방금 부른 모델은 열려 있음이
245
+ 보장되므로 그걸 쓴다 — 바꾸려면 evaluator(model=) 또는 conf ``llm_eval_judge_model``.
246
+ 스냅샷이 없으면(LiteLLM·직접 HTTP) 앱이 실제로 보낸 Bedrock 호스트에서 리전·엔드포인트를
247
+ 읽고 자격증명은 AWS 기본 체인에 맡긴다.
248
+ """
249
+ model = self._model or getattr(ctx, 'model', None)
250
+ if not model:
251
+ return None
252
+ aws = getattr(ctx, 'judge_aws', None) or {}
253
+ host = (getattr(ctx, 'provider', '') or '').lower()
254
+ match = _BEDROCK_HOST.search(host)
255
+ region = aws.get('region') or (match.group(1) if match else None)
256
+ # 호스트 전체를 쓴다 — VPC 엔드포인트는 'vpce-….bedrock-runtime…' 처럼 앞이 붙는다.
257
+ endpoint_url = aws.get('endpoint_url') or ('https://' + host if match else None)
258
+ profile = aws.get('profile')
259
+ credentials = aws.get('credentials')
260
+ # 자격증명은 객체 id 로 구분한다 — 캐시된 judge_fn 이 그 객체를 붙잡고 있으므로 캐시에
261
+ # 있는 동안 id 가 재사용될 일은 없다.
262
+ cache_key = ('bedrock', model, region or '', endpoint_url or '', profile or '',
263
+ id(credentials) if credentials is not None else 0)
264
+ judge_fn = self._auto_cache.get(cache_key)
265
+ if judge_fn is not None:
266
+ return judge_fn
267
+ judge_fn = make_bedrock_judge(
268
+ model=model, region_name=region, endpoint_url=endpoint_url,
269
+ profile_name=profile, credentials=credentials, timeout=timeout,
270
+ )
271
+ # 가장 오래된 것부터 버린다(dict 는 삽입 순서). 워커 동시 변경은 다음 번에 다시 맞춘다.
272
+ while len(self._auto_cache) >= _BEDROCK_CACHE_MAX:
273
+ try:
274
+ self._auto_cache.pop(next(iter(self._auto_cache)))
275
+ except (StopIteration, KeyError, RuntimeError):
276
+ break
277
+ self._auto_cache[cache_key] = judge_fn
278
+ return judge_fn
279
+
217
280
  def evaluate(self, ctx):
218
281
  judge_fn = self._resolve_judge_fn(ctx)
219
282
  if judge_fn is None:
@@ -0,0 +1,182 @@
1
+ # -*- coding: utf-8 -*-
2
+ """평가(Evaluation) ↔ 에이전트 코어의 접점 — 코어는 평가 기능을 이 모듈로만 부른다.
3
+
4
+ 코어(계측·송출·트레이스) 쪽 호출 지점은 전부 ``# >>> LLM-EVAL`` ~ ``# <<< LLM-EVAL``
5
+ 블록 안에 있고, 블록은 이 모듈(또는 평가 전용 모듈)만 import 한다. 그래서 평가 기능을
6
+ 떼어낼 때 블록만 지우면 코어에 남는 참조가 없다. @see whatap/llm/evaluators/REMOVAL.md
7
+
8
+ 맡는 일은 셋이다.
9
+
10
+ 1. **judge 호출 표시** — 평가 워커가 judge LLM 을 부르는 동안 켜진다. 계측은 이걸 보고
11
+ judge 호출을 사용자 호출과 가른다(평가 재귀 차단, ``operation_type=whatap_evaluation``,
12
+ 부모 스텝 좌표). thread-local 과 ContextVar 두 군데에 둔다 — 동기 judge 는 워커
13
+ 스레드에서 LLM 을 부르고, 앱 루프로 던진 코루틴은 ContextVar 만 물려받기 때문이다.
14
+ 2. **judge 대상 스냅샷** — 사용자 호출의 client 에서 judge 가 쓸 엔드포인트·자격증명을
15
+ 캡처 시점에 떠 둔다. client 인스턴스는 잡지 않는다(요청 스코프면 평가가 도는 시점엔
16
+ 이미 닫혀 있다). 평가가 꺼져 있으면 아무것도 뜨지 않는다.
17
+ 3. **평가 큐 투입** — 완료된 LLM 호출 팩을 평가 파이프라인에 넘긴다.
18
+ """
19
+ import contextvars
20
+ import inspect
21
+ import threading
22
+
23
+ JUDGE_OPERATION_TYPE = 'whatap_evaluation'
24
+
25
+ _worker_local = threading.local()
26
+ _worker_cv = contextvars.ContextVar('whatap_eval_worker', default=None)
27
+
28
+
29
+ def conf_truthy(name, default=False):
30
+ """Configure attr 을 bool 로 안전 변환.
31
+
32
+ Configure.setProperty 가 config file 에서 읽은 문자열 'true'/'false' 를 그대로
33
+ setattr 해서 string 으로 저장 → 'false' 도 truthy 로 평가되는 reload 버그 우회.
34
+ """
35
+ try:
36
+ from whatap.conf.configure import Configure as conf
37
+ val = getattr(conf, name, default)
38
+ except Exception:
39
+ return bool(default)
40
+ if isinstance(val, bool):
41
+ return val
42
+ if isinstance(val, str):
43
+ return val.strip().lower() in ('true', 'yes', '1', 'on')
44
+ return bool(val)
45
+
46
+
47
+ # ── 1. judge 호출 표시 ────────────────────────────────────────────────────────
48
+
49
+ def enter_judge_call(pack):
50
+ """평가 워커가 ``pack`` 을 평가하기 직전에 켠다. 반환값을 ``exit_judge_call`` 에 넘긴다.
51
+
52
+ 부모(사용자) 스텝 좌표를 같이 실어 둔다 — ``llm_eval_track_judge_calls=true`` 면 judge
53
+ 호출 팩이 이 좌표로 덮여 대시보드에서 사용자 호출과 같은 트랜잭션으로 보인다.
54
+ """
55
+ state = {
56
+ 'parent_txid': pack.txid,
57
+ 'parent_step_id': pack.step_id,
58
+ 'parent_index': pack.index,
59
+ }
60
+ _worker_local.state = state
61
+ return _worker_cv.set(state)
62
+
63
+
64
+ def exit_judge_call(token):
65
+ _worker_cv.reset(token)
66
+ _worker_local.state = None
67
+
68
+
69
+ def judge_call_state():
70
+ """judge 호출 중이면 부모 스텝 좌표 dict, 아니면 None. ContextVar 우선, thread-local 폴백."""
71
+ state = _worker_cv.get()
72
+ if state is not None:
73
+ return state
74
+ return getattr(_worker_local, 'state', None)
75
+
76
+
77
+ def is_judge_call():
78
+ """지금 이 호출이 평가 워커가 부른 judge 호출인가."""
79
+ return judge_call_state() is not None
80
+
81
+
82
+ def mark_judge_call(pack):
83
+ """judge 호출이면 팩에 표시를 남긴다 — 고정 라벨 + 부모(사용자) 스텝 좌표.
84
+
85
+ 메트릭은 ``operation_type=whatap_evaluation`` 으로 사용자 호출과 분리 집계되고, 로그싱크는
86
+ 사용자 스텝과 같은 (txid, step_id, index) 로 묶인다. 반환: judge 호출이었는가.
87
+ """
88
+ state = judge_call_state()
89
+ if state is None:
90
+ return False
91
+ pack.operation_type = JUDGE_OPERATION_TYPE
92
+ pack.prompt_version = 'v1'
93
+ if state.get('parent_txid'):
94
+ pack.txid = state['parent_txid']
95
+ if state.get('parent_step_id'):
96
+ pack.step_id = state['parent_step_id']
97
+ if state.get('parent_index') is not None:
98
+ pack.index = state['parent_index']
99
+ return True
100
+
101
+
102
+ # ── 2. judge 대상 스냅샷 ──────────────────────────────────────────────────────
103
+
104
+ def snapshot_judge_client(pack, llm_client):
105
+ """openai / anthropic SDK client 에서 judge 대상을 떠 둔다 — 인스턴스 말고 '값'만.
106
+
107
+ - 일반: base_url / api_key 문자열 → judge 가 같은 엔드포인트·키로 자기 client 를 만든다.
108
+ - ``AnthropicBedrock`` 계열: 자격증명이 API 키가 아니라 AWS(SigV4)라 judge 도 Bedrock
109
+ Runtime 으로 보낸다(같은 리전·프로파일·명시 키). 예전엔 이 client 도 'anthropic' 으로
110
+ 떠서 judge 가 api.anthropic.com 을 Bedrock 모델 ID 로 불러 평가가 늘 실패했다.
111
+ """
112
+ if not conf_truthy('llm_eval_enabled'):
113
+ return
114
+ cls = type(llm_client).__name__
115
+ base_url = getattr(llm_client, 'base_url', None)
116
+ if 'Bedrock' in cls:
117
+ _snapshot_anthropic_bedrock(pack, llm_client, str(base_url or ''))
118
+ return
119
+ pack._llm_judge_kind = 'anthropic' if 'Anthropic' in cls else 'openai'
120
+ api_key = getattr(llm_client, 'api_key', None)
121
+ if api_key:
122
+ pack._llm_judge_api_key = str(api_key)
123
+ if base_url:
124
+ pack._llm_judge_base_url = str(base_url)
125
+
126
+
127
+ def snapshot_bedrock_client(pack, client):
128
+ """boto3 / aiobotocore ``bedrock-runtime`` client 에서 judge 대상을 떠 둔다.
129
+
130
+ 리전·엔드포인트는 값으로, 자격증명은 **botocore 자격증명 객체**로 잡는다. client 와 달리
131
+ 닫히는 일이 없고, 갱신형(assume-role·IMDS·SSO)은 judge 시점에 스스로 새 값을 받아 온다
132
+ — 값으로 복사하면 그 갱신을 잃어 만료된다. 기본 자격증명 체인에 맡기지 않는 이유는
133
+ 신원이다: 앱이 명시 키·다른 프로파일을 쓰면 기본 체인은 다른 계정을 잡는다.
134
+
135
+ aiobotocore 자격증명은 갱신이 코루틴(앱 이벤트 루프 소속)이라 평가 워커 스레드에서 쓸 수
136
+ 없다 — 그때는 비워 두고 judge 가 AWS 기본 자격증명 체인을 쓴다.
137
+ """
138
+ if not conf_truthy('llm_eval_enabled'):
139
+ return
140
+ meta = getattr(client, 'meta', None)
141
+ creds = getattr(getattr(client, '_request_signer', None), '_credentials', None)
142
+ if creds is not None and inspect.iscoroutinefunction(
143
+ getattr(creds, 'get_frozen_credentials', None)):
144
+ creds = None
145
+ _set_bedrock_target(pack, getattr(meta, 'region_name', None),
146
+ getattr(meta, 'endpoint_url', None), None, creds)
147
+
148
+
149
+ def _snapshot_anthropic_bedrock(pack, llm_client, base_url):
150
+ creds = None
151
+ access_key = getattr(llm_client, 'aws_access_key', None)
152
+ secret_key = getattr(llm_client, 'aws_secret_key', None)
153
+ if access_key and secret_key:
154
+ try:
155
+ from botocore.credentials import Credentials
156
+ creds = Credentials(access_key, secret_key,
157
+ getattr(llm_client, 'aws_session_token', None))
158
+ except Exception:
159
+ creds = None
160
+ # Converse 를 서빙하지 않는 엔드포인트(bedrock-mantle 등)는 따라가지 않는다 — 리전 기본값.
161
+ endpoint_url = base_url if 'bedrock-runtime' in base_url else None
162
+ _set_bedrock_target(pack, getattr(llm_client, 'aws_region', None), endpoint_url,
163
+ getattr(llm_client, 'aws_profile', None), creds)
164
+
165
+
166
+ def _set_bedrock_target(pack, region, endpoint_url, profile, credentials):
167
+ pack._llm_judge_kind = 'bedrock'
168
+ pack._llm_judge_aws = {
169
+ 'region': region or None,
170
+ 'endpoint_url': (str(endpoint_url).rstrip('/') or None) if endpoint_url else None,
171
+ 'profile': profile or None,
172
+ 'credentials': credentials,
173
+ }
174
+
175
+
176
+ # ── 3. 평가 큐 ────────────────────────────────────────────────────────────────
177
+
178
+ def enqueue(pack):
179
+ """완료된 LLM 호출 팩을 평가 파이프라인에 넘긴다. judge 호출은 거기서 걸러진다."""
180
+ # cycle-break(llm↔counter): 함수 스코프 지연 import (표준 방향의 역방향).
181
+ from whatap.counter.tasks.llm_evaluator_task import enqueue_evaluation
182
+ enqueue_evaluation(pack)