google-cloud-agentplatform 1.165.1.dev0__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. agentplatform/__init__.py +72 -0
  2. agentplatform/_genai/__init__.py +43 -0
  3. agentplatform/_genai/_agent_engines_utils.py +2341 -0
  4. agentplatform/_genai/_bigquery_utils.py +49 -0
  5. agentplatform/_genai/_datasets_utils.py +344 -0
  6. agentplatform/_genai/_evals_builtin_tools.py +209 -0
  7. agentplatform/_genai/_evals_common.py +4268 -0
  8. agentplatform/_genai/_evals_constant.py +122 -0
  9. agentplatform/_genai/_evals_data_converters.py +926 -0
  10. agentplatform/_genai/_evals_metric_handlers.py +1783 -0
  11. agentplatform/_genai/_evals_metric_loaders.py +401 -0
  12. agentplatform/_genai/_evals_utils.py +1043 -0
  13. agentplatform/_genai/_evals_visualization.py +2070 -0
  14. agentplatform/_genai/_gcs_utils.py +262 -0
  15. agentplatform/_genai/_logging_utils.py +47 -0
  16. agentplatform/_genai/_memory_bank_utils.py +206 -0
  17. agentplatform/_genai/_observability_data_converter.py +186 -0
  18. agentplatform/_genai/_operations_utils.py +94 -0
  19. agentplatform/_genai/_prompt_management_utils.py +147 -0
  20. agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
  21. agentplatform/_genai/_skills_utils.py +69 -0
  22. agentplatform/_genai/_transformers.py +628 -0
  23. agentplatform/_genai/a2a_task_events.py +509 -0
  24. agentplatform/_genai/a2a_tasks.py +861 -0
  25. agentplatform/_genai/agent_engines.py +3931 -0
  26. agentplatform/_genai/client.py +519 -0
  27. agentplatform/_genai/datasets.py +3045 -0
  28. agentplatform/_genai/endpoints.py +1149 -0
  29. agentplatform/_genai/evals.py +6883 -0
  30. agentplatform/_genai/example_stores.py +1445 -0
  31. agentplatform/_genai/feedback_contexts.py +700 -0
  32. agentplatform/_genai/feedback_entries.py +1644 -0
  33. agentplatform/_genai/live.py +64 -0
  34. agentplatform/_genai/live_agent_engines.py +179 -0
  35. agentplatform/_genai/memories.py +2962 -0
  36. agentplatform/_genai/memory_banks.py +1927 -0
  37. agentplatform/_genai/memory_revisions.py +465 -0
  38. agentplatform/_genai/model_garden.py +2638 -0
  39. agentplatform/_genai/prompt_optimizer.py +995 -0
  40. agentplatform/_genai/prompts.py +4515 -0
  41. agentplatform/_genai/rag.py +4961 -0
  42. agentplatform/_genai/runtime_revisions.py +1257 -0
  43. agentplatform/_genai/runtimes.py +78 -0
  44. agentplatform/_genai/sandbox_snapshots.py +1015 -0
  45. agentplatform/_genai/sandbox_templates.py +1088 -0
  46. agentplatform/_genai/sandboxes.py +1604 -0
  47. agentplatform/_genai/session_events.py +543 -0
  48. agentplatform/_genai/sessions.py +1449 -0
  49. agentplatform/_genai/skill_revisions.py +377 -0
  50. agentplatform/_genai/skills.py +1708 -0
  51. agentplatform/_genai/types/__init__.py +4695 -0
  52. agentplatform/_genai/types/agent_engines.py +16 -0
  53. agentplatform/_genai/types/common.py +32784 -0
  54. agentplatform/_genai/types/evals.py +1031 -0
  55. agentplatform/_genai/types/prompt_optimizer.py +107 -0
  56. agentplatform/_genai/types/prompts.py +107 -0
  57. agentplatform/version.py +17 -0
  58. google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
  59. google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
  60. google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
  61. google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
  62. google_cloud_agentplatform-1.165.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1043 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ """Utility functions for evals."""
16
+
17
+ import abc
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import os
22
+ import threading
23
+ import time
24
+ from typing import Any, Optional, Union
25
+
26
+ from google.genai._api_client import BaseApiClient
27
+ from google.genai._common import get_value_by_path as getv
28
+ from google.genai._common import set_value_by_path as setv
29
+ import pandas as pd
30
+
31
+ from . import _bigquery_utils
32
+ from . import _gcs_utils
33
+ from . import _transformers
34
+ from . import types
35
+
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ GCS_PREFIX = "gs://"
41
+ BQ_PREFIX = "bq://"
42
+ _DEFAULT_EVAL_SERVICE_QPS = 10
43
+
44
+
45
+ class RateLimiter:
46
+ """Helper class for rate-limiting requests to Vertex AI to improve QoS.
47
+
48
+ Implements a token bucket algorithm to limit the rate at which API calls
49
+ can occur. Designed for cases where the batch size is always 1 for traffic
50
+ shaping and rate limiting.
51
+
52
+ Attributes:
53
+ seconds_per_event: The time interval (in seconds) between events to
54
+ maintain the desired rate.
55
+ last: The timestamp of the last event.
56
+ _lock: A lock to ensure thread safety.
57
+ """
58
+
59
+ def __init__(self, rate: float) -> None:
60
+ """Initializes the rate limiter.
61
+
62
+ Args:
63
+ rate: The number of queries allowed per second.
64
+
65
+ Raises:
66
+ ValueError: If the rate is not positive.
67
+ """
68
+ if not rate or rate <= 0:
69
+ raise ValueError("Rate must be a positive number")
70
+ self.seconds_per_event = 1.0 / rate
71
+ self._next_allowed = time.monotonic()
72
+ self._lock = threading.Lock()
73
+
74
+ def sleep_and_advance(self) -> None:
75
+ """Blocks the current thread until the next event can be admitted.
76
+
77
+ The lock is held only long enough to reserve a time slot. The
78
+ actual sleep happens outside the lock so that multiple threads
79
+ can be sleeping concurrently with staggered wake-up times.
80
+ """
81
+ with self._lock:
82
+ now = time.monotonic()
83
+ wait_until = max(now, self._next_allowed)
84
+ delay = wait_until - now
85
+ self._next_allowed = wait_until + self.seconds_per_event
86
+
87
+ if delay > 0:
88
+ time.sleep(delay)
89
+
90
+
91
+ class EvalDatasetLoader:
92
+ """A loader for datasets from various sources, using a shared client."""
93
+
94
+ def __init__(self, api_client: BaseApiClient) -> None:
95
+ self.api_client = api_client
96
+ self.gcs_utils = _gcs_utils.GcsUtils(self.api_client)
97
+ self.bigquery_utils = _bigquery_utils.BigQueryUtils(self.api_client)
98
+
99
+ def _load_file(
100
+ self, filepath: str, file_type: str
101
+ ) -> Union[list[dict[str, Any]], Any]:
102
+ """Loads data from a file into a list of dictionaries."""
103
+ if filepath.startswith(GCS_PREFIX):
104
+ df = self.gcs_utils.read_gcs_file_to_dataframe(filepath, file_type)
105
+ return df.to_dict(orient="records")
106
+ else:
107
+ if file_type == "jsonl":
108
+ df = pd.read_json(filepath, lines=True)
109
+ return df.to_dict(orient="records")
110
+ elif file_type == "csv":
111
+ df = pd.read_csv(filepath, encoding="utf-8")
112
+ return df.to_dict(orient="records")
113
+ else:
114
+ raise ValueError(
115
+ f"Unsupported file type: '{file_type}'. Please provide 'jsonl' or"
116
+ " 'csv'."
117
+ )
118
+
119
+ def load(
120
+ self, source: Union[str, "pd.DataFrame"]
121
+ ) -> Union[list[dict[str, Any]], Any]:
122
+ """Loads dataset from various sources into a list of dictionaries."""
123
+ if isinstance(source, pd.DataFrame):
124
+ return source.to_dict(orient="records")
125
+ elif isinstance(source, str):
126
+ if source.startswith(BQ_PREFIX):
127
+ df = self.bigquery_utils.load_bigquery_to_dataframe(
128
+ source[len(BQ_PREFIX) :]
129
+ )
130
+ return df.to_dict(orient="records")
131
+
132
+ _, extension = os.path.splitext(source)
133
+ file_type = extension.lower()[1:]
134
+
135
+ if file_type == "jsonl":
136
+ return self._load_file(source, "jsonl")
137
+ elif file_type == "csv":
138
+ return self._load_file(source, "csv")
139
+ else:
140
+ raise TypeError(
141
+ f"Unsupported file type: {file_type} from {source}. Please"
142
+ " provide a valid GCS path with `jsonl` or `csv` suffix, "
143
+ "a local file path, or a valid BigQuery table URI."
144
+ )
145
+ else:
146
+ raise TypeError(
147
+ "Unsupported dataset type. Must be a `pd.DataFrame`, Python"
148
+ " a valid GCS path with `jsonl` or `csv` suffix, a local"
149
+ " file path, or a valid BigQuery table URI."
150
+ )
151
+
152
+
153
+ class BatchEvaluateRequestPreparer:
154
+ """Prepares data for requests."""
155
+
156
+ @staticmethod
157
+ def _EvaluationDataset_to_vertex(
158
+ from_object: Union[dict[str, Any], object],
159
+ parent_object: Optional[dict[str, Any]] = None,
160
+ ) -> dict[str, Any]:
161
+ to_object: dict[str, Any] = {}
162
+
163
+ if getv(from_object, ["gcs_source"]) is not None:
164
+ setv(
165
+ to_object,
166
+ ["gcs_source"],
167
+ getv(from_object, ["gcs_source"]),
168
+ )
169
+
170
+ if getv(from_object, ["bigquery_source"]) is not None:
171
+ setv(
172
+ to_object,
173
+ ["bigquery_source"],
174
+ getv(from_object, ["bigquery_source"]),
175
+ )
176
+
177
+ return to_object
178
+
179
+ @staticmethod
180
+ def _Metric_to_vertex(
181
+ from_object: Union[dict[str, Any], object],
182
+ parent_object: Optional[dict[str, Any]] = None,
183
+ ) -> dict[str, Any]:
184
+ to_object: dict[str, Any] = {}
185
+
186
+ if getv(from_object, ["prompt_template"]) is not None:
187
+ setv(
188
+ to_object,
189
+ ["pointwise_metric_spec", "prompt_template"],
190
+ getv(from_object, ["prompt_template"]),
191
+ )
192
+
193
+ if getv(from_object, ["judge_model"]) is not None:
194
+ setv(
195
+ parent_object,
196
+ ["autorater_config", "autorater_model"],
197
+ getv(from_object, ["judge_model"]),
198
+ )
199
+
200
+ if getv(from_object, ["judge_model_sampling_count"]) is not None:
201
+ setv(
202
+ parent_object,
203
+ ["autorater_config", "sampling_count"],
204
+ getv(from_object, ["judge_model_sampling_count"]),
205
+ )
206
+
207
+ if getv(from_object, ["judge_model_system_instruction"]) is not None:
208
+ setv(
209
+ to_object,
210
+ ["pointwise_metric_spec", "system_instruction"],
211
+ getv(from_object, ["judge_model_system_instruction"]),
212
+ )
213
+
214
+ if getv(from_object, ["return_raw_output"]) is not None:
215
+ setv(
216
+ to_object,
217
+ [
218
+ "pointwise_metric_spec",
219
+ "custom_output_format_config",
220
+ "return_raw_output",
221
+ ],
222
+ getv(from_object, ["return_raw_output"]),
223
+ )
224
+
225
+ return to_object
226
+
227
+ @staticmethod
228
+ def _OutputConfig_to_vertex(
229
+ from_object: Union[dict[str, Any], object],
230
+ parent_object: Optional[dict[str, Any]] = None,
231
+ ) -> dict[str, Any]:
232
+ to_object: dict[str, Any] = {}
233
+ if getv(from_object, ["gcs_destination"]) is not None:
234
+ setv(
235
+ to_object,
236
+ ["gcsDestination"],
237
+ getv(from_object, ["gcs_destination"]),
238
+ )
239
+
240
+ return to_object
241
+
242
+ @staticmethod
243
+ def _EvaluationDataset_from_vertex(
244
+ from_object: Union[dict[str, Any], object],
245
+ parent_object: Optional[dict[str, Any]] = None,
246
+ ) -> dict[str, Any]:
247
+ to_object: dict[str, Any] = {}
248
+
249
+ if getv(from_object, ["dataset", "gcs_source"]) is not None:
250
+ setv(
251
+ to_object,
252
+ ["gcs_source"],
253
+ getv(from_object, ["dataset", "gcs_source"]),
254
+ )
255
+
256
+ if getv(from_object, ["dataset", "bigquery_source"]) is not None:
257
+ setv(
258
+ to_object,
259
+ ["bigquery_source"],
260
+ getv(from_object, ["dataset", "bigquery_source"]),
261
+ )
262
+
263
+ return to_object
264
+
265
+ @staticmethod
266
+ def _AutoraterConfig_to_vertex(
267
+ from_object: Union[dict[str, Any], object],
268
+ parent_object: Optional[dict[str, Any]] = None,
269
+ ) -> dict[str, Any]:
270
+ to_object: dict[str, Any] = {}
271
+ if getv(from_object, ["sampling_count"]) is not None:
272
+ setv(to_object, ["samplingCount"], getv(from_object, ["sampling_count"]))
273
+
274
+ if getv(from_object, ["flip_enabled"]) is not None:
275
+ setv(to_object, ["flipEnabled"], getv(from_object, ["flip_enabled"]))
276
+
277
+ if getv(from_object, ["autorater_model"]) is not None:
278
+ setv(
279
+ to_object,
280
+ ["autoraterModel"],
281
+ getv(from_object, ["autorater_model"]),
282
+ )
283
+
284
+ return to_object
285
+
286
+ @staticmethod
287
+ def EvaluateDatasetOperation_from_vertex(
288
+ from_object: Union[dict[str, Any], object],
289
+ parent_object: Optional[dict[str, Any]] = None,
290
+ ) -> dict[str, Any]:
291
+ to_object: dict[str, Any] = {}
292
+ if getv(from_object, ["name"]) is not None:
293
+ setv(to_object, ["name"], getv(from_object, ["name"]))
294
+
295
+ if getv(from_object, ["metadata"]) is not None:
296
+ setv(to_object, ["metadata"], getv(from_object, ["metadata"]))
297
+
298
+ if getv(from_object, ["done"]) is not None:
299
+ setv(to_object, ["done"], getv(from_object, ["done"]))
300
+
301
+ if getv(from_object, ["error"]) is not None:
302
+ setv(to_object, ["error"], getv(from_object, ["error"]))
303
+
304
+ if getv(from_object, ["response"]) is not None:
305
+ setv(
306
+ to_object,
307
+ ["response"],
308
+ BatchEvaluateRequestPreparer._EvaluationDataset_from_vertex(
309
+ getv(from_object, ["response"]), to_object
310
+ ),
311
+ )
312
+
313
+ return to_object
314
+
315
+ @staticmethod
316
+ def EvaluateDatasetRequestParameters_to_vertex(
317
+ from_object: Union[dict[str, Any], object],
318
+ parent_object: Optional[dict[str, Any]] = None,
319
+ ) -> dict[str, Any]:
320
+ to_object: dict[str, Any] = {}
321
+ if getv(from_object, ["dataset"]) is not None:
322
+ setv(
323
+ to_object,
324
+ ["dataset"],
325
+ BatchEvaluateRequestPreparer._EvaluationDataset_to_vertex(
326
+ getv(from_object, ["dataset"]), to_object
327
+ ),
328
+ )
329
+
330
+ if getv(from_object, ["metrics"]) is not None:
331
+ setv(
332
+ to_object,
333
+ ["metrics"],
334
+ [
335
+ BatchEvaluateRequestPreparer._Metric_to_vertex(item, to_object)
336
+ for item in getv(from_object, ["metrics"])
337
+ ],
338
+ )
339
+
340
+ if getv(from_object, ["output_config"]) is not None:
341
+ setv(
342
+ to_object,
343
+ ["outputConfig"],
344
+ BatchEvaluateRequestPreparer._OutputConfig_to_vertex(
345
+ getv(from_object, ["output_config"]), to_object
346
+ ),
347
+ )
348
+
349
+ if getv(from_object, ["autorater_config"]) is not None:
350
+ setv(
351
+ to_object,
352
+ ["autoraterConfig"],
353
+ BatchEvaluateRequestPreparer._AutoraterConfig_to_vertex(
354
+ getv(from_object, ["autorater_config"]), to_object
355
+ ),
356
+ )
357
+
358
+ if getv(from_object, ["config"]) is not None:
359
+ setv(to_object, ["config"], getv(from_object, ["config"]))
360
+
361
+ return to_object
362
+
363
+ @staticmethod
364
+ def prepare_metric_payload(
365
+ request_dict: dict[str, Any], resolved_metrics: list["types.MetricSubclass"]
366
+ ) -> dict[str, Any]:
367
+ """Prepares the metric payload for the evaluation request.
368
+
369
+ Args:
370
+ request_dict: The dictionary containing the request details.
371
+ resolved_metrics: A list of resolved metric objects.
372
+
373
+ Returns:
374
+ The updated request dictionary with the prepared metric payload.
375
+ """
376
+ request_dict["metrics"] = _transformers.t_metrics(
377
+ resolved_metrics, set_default_aggregation_metrics=True
378
+ )
379
+ return request_dict
380
+
381
+
382
+ class EvalDataConverter(abc.ABC):
383
+ """Abstract base class for dataset converters."""
384
+
385
+ @abc.abstractmethod
386
+ def convert(self, raw_data: Any) -> "types.EvaluationDataset":
387
+ """Converts a loaded raw dataset into an EvaluationDataset."""
388
+ raise NotImplementedError()
389
+
390
+
391
+ def _postprocess_user_scenarios_response(
392
+ response: types.GenerateUserScenariosResponse,
393
+ ) -> types.EvaluationDataset:
394
+ """Postprocesses the response from generating user scenarios."""
395
+ eval_cases = []
396
+ data_for_df = []
397
+ if hasattr(response, "user_scenarios") and response.user_scenarios:
398
+ for scenario in response.user_scenarios:
399
+ eval_case = types.EvalCase(
400
+ user_scenario=scenario,
401
+ )
402
+ eval_cases.append(eval_case)
403
+ data_for_df.append(
404
+ {
405
+ "starting_prompt": scenario.starting_prompt,
406
+ "conversation_plan": scenario.conversation_plan,
407
+ }
408
+ )
409
+ eval_dataset_df = None
410
+ if pd is not None:
411
+ eval_dataset_df = pd.DataFrame(data_for_df)
412
+ else:
413
+ logger.warning("Pandas is not installed. eval_dataset_df will be None.")
414
+ return types.EvaluationDataset(
415
+ eval_cases=eval_cases, eval_dataset_df=eval_dataset_df
416
+ )
417
+
418
+
419
+ def _display_loss_analysis_result(
420
+ result: types.LossAnalysisResult,
421
+ ) -> None:
422
+ """Displays a LossAnalysisResult as a formatted pandas DataFrame."""
423
+ metric = result.config.metric if result.config else None
424
+ candidate = result.config.candidate if result.config else None
425
+ rows: list[dict[str, Any]] = []
426
+ for cluster in result.clusters or []:
427
+ entry = cluster.taxonomy_entry
428
+ row = {
429
+ "metric": metric,
430
+ "candidate": candidate,
431
+ "cluster_id": cluster.cluster_id,
432
+ "l1_category": entry.l1_category if entry else None,
433
+ "l2_category": entry.l2_category if entry else None,
434
+ "description": entry.description if entry else None,
435
+ "item_count": cluster.item_count,
436
+ }
437
+ rows.append(row)
438
+
439
+ if not rows:
440
+ logger.info("No loss clusters found.")
441
+ return
442
+
443
+ df = pd.DataFrame(rows)
444
+ try:
445
+ from IPython.display import display # pylint: disable=g-import-not-at-top
446
+
447
+ display(df)
448
+ except ImportError:
449
+ print(df.to_string()) # pylint: disable=print-function
450
+
451
+
452
+ def _resolve_metric_name(
453
+ metric: Optional[Any],
454
+ ) -> Optional[str]:
455
+ """Extracts a metric name string from a metric argument.
456
+
457
+ Accepts a string, a Metric object, or a LazyLoadedPrebuiltMetric
458
+ (RubricMetric) and returns the metric name as a string.
459
+
460
+ For LazyLoadedPrebuiltMetric (e.g., RubricMetric.MULTI_TURN_TASK_SUCCESS),
461
+ this resolves to the API metric spec name (e.g.,
462
+ "multi_turn_task_success_v1") so it matches the keys in eval results.
463
+
464
+ Args:
465
+ metric: A metric name string, Metric object, RubricMetric enum value, or
466
+ None.
467
+
468
+ Returns:
469
+ The metric name as a string, or None if metric is None.
470
+ """
471
+ if metric is None:
472
+ return None
473
+ if isinstance(metric, str):
474
+ return metric
475
+ # LazyLoadedPrebuiltMetric: resolve to versioned API spec name.
476
+ if hasattr(metric, "_get_api_metric_spec_name"):
477
+ spec_name: Optional[str] = metric._get_api_metric_spec_name()
478
+ if spec_name:
479
+ return spec_name
480
+ # Metric objects and other types with a .name attribute.
481
+ if hasattr(metric, "name"):
482
+ return str(metric.name)
483
+ return str(metric)
484
+
485
+
486
+ def _resolve_eval_run_loss_configs(
487
+ loss_analysis_metrics: Optional[list[Any]] = None,
488
+ loss_analysis_configs: Optional[list[Any]] = None,
489
+ inference_configs: Optional[dict[str, Any]] = None,
490
+ ) -> Optional[list[types.LossAnalysisConfig]]:
491
+ """Resolves loss analysis configs for create_evaluation_run.
492
+
493
+ Supports two modes:
494
+ 1. ``loss_analysis_metrics``: A simplified list of metrics. The candidate
495
+ is auto-inferred from ``inference_configs`` when there is exactly one
496
+ candidate. Each metric is resolved via ``_resolve_metric_name()``.
497
+ 2. ``loss_analysis_configs``: Explicit ``LossAnalysisConfig`` objects or
498
+ dicts for full control.
499
+
500
+ Args:
501
+ loss_analysis_metrics: Optional list of metric references (strings,
502
+ Metric objects, or RubricMetric enums).
503
+ loss_analysis_configs: Optional list of LossAnalysisConfig or dicts.
504
+ inference_configs: The resolved inference_configs dict (candidate name
505
+ -> config). Used to auto-infer candidate for the metrics path.
506
+
507
+ Returns:
508
+ A list of resolved LossAnalysisConfig objects, or None if neither
509
+ loss_analysis_metrics nor loss_analysis_configs is provided.
510
+
511
+ Raises:
512
+ ValueError: If candidate cannot be inferred for loss_analysis_metrics.
513
+ """
514
+ if not loss_analysis_metrics and not loss_analysis_configs:
515
+ return None
516
+
517
+ if loss_analysis_configs:
518
+ return [
519
+ types.LossAnalysisConfig.model_validate(c) if isinstance(c, dict) else c
520
+ for c in loss_analysis_configs
521
+ ]
522
+
523
+ # loss_analysis_metrics path: auto-infer candidate from inference_configs
524
+ candidate = None
525
+ if inference_configs and len(inference_configs) == 1:
526
+ candidate = next(iter(inference_configs))
527
+ elif inference_configs and len(inference_configs) > 1:
528
+ raise ValueError(
529
+ "Cannot infer candidate for loss analysis: multiple candidates"
530
+ f" found in inference_configs: {list(inference_configs.keys())}."
531
+ " Please use loss_analysis_configs with explicit candidate values"
532
+ " instead."
533
+ )
534
+
535
+ configs = []
536
+ for m in loss_analysis_metrics or []:
537
+ metric_name = _resolve_metric_name(m)
538
+ configs.append(
539
+ types.LossAnalysisConfig(metric=metric_name, candidate=candidate)
540
+ )
541
+ return configs
542
+
543
+
544
+ def _resolve_red_teaming_config(
545
+ red_teaming_config: Optional[types.RedTeamingAnalysisConfigOrDict] = None,
546
+ ) -> Optional[list[types.AnalysisConfig]]:
547
+ """Wraps a RedTeamingAnalysisConfig into analysis_configs for the API."""
548
+ if not red_teaming_config:
549
+ return None
550
+ config = (
551
+ types.RedTeamingAnalysisConfig.model_validate(red_teaming_config)
552
+ if isinstance(red_teaming_config, dict)
553
+ else red_teaming_config
554
+ )
555
+ return [types.AnalysisConfig(red_teaming_analysis_config=config)]
556
+
557
+
558
+ def _resolve_loss_analysis_config(
559
+ eval_result: types.EvaluationResult,
560
+ config: Optional[types.LossAnalysisConfig] = None,
561
+ metric: Optional[str] = None,
562
+ candidate: Optional[str] = None,
563
+ ) -> types.LossAnalysisConfig:
564
+ """Resolves and validates the LossAnalysisConfig for generate_loss_clusters.
565
+
566
+ Auto-infers `metric` and `candidate` from the EvaluationResult when not
567
+ explicitly provided. Validates that provided values exist in the eval result.
568
+
569
+ Args:
570
+ eval_result: The EvaluationResult from client.evals.evaluate().
571
+ config: Optional explicit LossAnalysisConfig. If provided, metric and
572
+ candidate from config take precedence over the separate arguments.
573
+ metric: Optional metric name override.
574
+ candidate: Optional candidate name override.
575
+
576
+ Returns:
577
+ A resolved LossAnalysisConfig with metric and candidate populated.
578
+
579
+ Raises:
580
+ ValueError: If metric/candidate cannot be inferred or are invalid.
581
+ """
582
+ # Start from config if provided, otherwise create a new one.
583
+ if config is not None:
584
+ resolved_metric = metric or config.metric
585
+ resolved_candidate = candidate or config.candidate
586
+ resolved_config = config.model_copy(
587
+ update={"metric": resolved_metric, "candidate": resolved_candidate}
588
+ )
589
+ else:
590
+ resolved_config = types.LossAnalysisConfig(metric=metric, candidate=candidate)
591
+
592
+ # Collect available metric names from the eval result.
593
+ available_metrics: set[str] = set()
594
+ if eval_result.eval_case_results:
595
+ for case_result in eval_result.eval_case_results:
596
+ for resp_cand in case_result.response_candidate_results or []:
597
+ for m_name in (resp_cand.metric_results or {}).keys():
598
+ available_metrics.add(m_name)
599
+
600
+ # Collect available candidate names from metadata.
601
+ available_candidates: list[str] = []
602
+ if eval_result.metadata and eval_result.metadata.candidate_names:
603
+ available_candidates = list(eval_result.metadata.candidate_names)
604
+
605
+ # Auto-infer metric if not provided.
606
+ if not resolved_config.metric:
607
+ if len(available_metrics) == 1:
608
+ resolved_config = resolved_config.model_copy(
609
+ update={"metric": next(iter(available_metrics))}
610
+ )
611
+ elif len(available_metrics) == 0:
612
+ raise ValueError(
613
+ "Cannot infer metric: no metric results found in eval_result."
614
+ " Please provide metric explicitly via"
615
+ " config=types.LossAnalysisConfig(metric='...')."
616
+ )
617
+ else:
618
+ raise ValueError(
619
+ "Cannot infer metric: multiple metrics found in eval_result:"
620
+ f" {sorted(available_metrics)}. Please provide metric"
621
+ " explicitly via config=types.LossAnalysisConfig(metric='...')."
622
+ )
623
+
624
+ # Validate metric if provided explicitly.
625
+ if available_metrics and resolved_config.metric not in available_metrics:
626
+ raise ValueError(
627
+ f"Metric '{resolved_config.metric}' not found in eval_result."
628
+ f" Available metrics: {sorted(available_metrics)}."
629
+ )
630
+
631
+ # Auto-infer candidate if not provided.
632
+ if not resolved_config.candidate:
633
+ if len(available_candidates) == 1:
634
+ resolved_config = resolved_config.model_copy(
635
+ update={"candidate": available_candidates[0]}
636
+ )
637
+ elif len(available_candidates) == 0:
638
+ # Fallback: use default candidate naming convention from SDK.
639
+ resolved_config = resolved_config.model_copy(
640
+ update={"candidate": "candidate_1"}
641
+ )
642
+ logger.warning(
643
+ "No candidate names found in eval_result.metadata."
644
+ " Defaulting to 'candidate_1'. If this is incorrect, provide"
645
+ " candidate explicitly via"
646
+ " config=types.LossAnalysisConfig(candidate='...')."
647
+ )
648
+ else:
649
+ raise ValueError(
650
+ "Cannot infer candidate: multiple candidates found in"
651
+ f" eval_result: {available_candidates}. Please provide"
652
+ " candidate explicitly via"
653
+ " config=types.LossAnalysisConfig(candidate='...')."
654
+ )
655
+
656
+ # Validate candidate if provided explicitly and candidates are known.
657
+ if available_candidates and resolved_config.candidate not in available_candidates:
658
+ raise ValueError(
659
+ f"Candidate '{resolved_config.candidate}' not found in"
660
+ f" eval_result. Available candidates: {available_candidates}."
661
+ )
662
+
663
+ return resolved_config
664
+
665
+
666
+ def _build_rubric_description_map(
667
+ eval_result: types.EvaluationResult,
668
+ ) -> dict[str, str]:
669
+ """Builds a rubric_id -> description map from the EvaluationResult."""
670
+ rubric_map: dict[str, str] = {}
671
+ for case_result in eval_result.eval_case_results or []:
672
+ for resp_cand in case_result.response_candidate_results or []:
673
+ for metric_res in (resp_cand.metric_results or {}).values():
674
+ for verdict in metric_res.rubric_verdicts or []:
675
+ rubric = verdict.evaluated_rubric
676
+ if rubric and rubric.rubric_id and rubric.content:
677
+ if (
678
+ rubric.content.property
679
+ and rubric.content.property.description
680
+ ):
681
+ rubric_map[rubric.rubric_id] = (
682
+ rubric.content.property.description
683
+ )
684
+ return rubric_map
685
+
686
+
687
+ def _extract_scenario_preview_from_dict(
688
+ eval_result_dict: dict[str, Any],
689
+ ) -> Optional[str]:
690
+ """Extracts the first user message from an evaluation_result dict.
691
+
692
+ Handles both snake_case (SDK-side) and camelCase (API echo-back) keys.
693
+ """
694
+ request = eval_result_dict.get("request")
695
+ if not request:
696
+ return None
697
+ prompt = request.get("prompt")
698
+ if not prompt:
699
+ return None
700
+ # Try agent_data (snake_case or camelCase)
701
+ agent_data = prompt.get("agent_data") or prompt.get("agentData")
702
+ if agent_data and isinstance(agent_data, dict):
703
+ turns = agent_data.get("turns", [])
704
+ for turn in turns:
705
+ events = turn.get("events", [])
706
+ for event in events:
707
+ author = event.get("author", "")
708
+ content = event.get("content")
709
+ if author.lower() == "user" and content and isinstance(content, dict):
710
+ parts = content.get("parts", [])
711
+ for part in parts:
712
+ text = str(part.get("text", "")).strip()
713
+ if text:
714
+ if len(text) > 150:
715
+ return text[:150] + "..."
716
+ return text
717
+ # Try simple prompt path
718
+ parts = prompt.get("parts", [])
719
+ for part in parts:
720
+ text = str(part.get("text", "")).strip()
721
+ if text:
722
+ if len(text) > 150:
723
+ return text[:150] + "..."
724
+ return text
725
+ return None
726
+
727
+
728
+ def _extract_scenario_from_agent_data(agent_data: Any) -> Optional[str]:
729
+ """Extracts the first user message from an AgentData object or dict."""
730
+ if agent_data is None:
731
+ return None
732
+ if hasattr(agent_data, "model_dump"):
733
+ agent_data = agent_data.model_dump()
734
+ if isinstance(agent_data, str):
735
+ try:
736
+ agent_data = json.loads(agent_data)
737
+ except (json.JSONDecodeError, ValueError):
738
+ return None
739
+ if not isinstance(agent_data, dict):
740
+ return None
741
+ turns = agent_data.get("turns", [])
742
+ if not isinstance(turns, list):
743
+ return None
744
+ for turn in turns:
745
+ if not isinstance(turn, dict):
746
+ continue
747
+ events = turn.get("events", [])
748
+ if not isinstance(events, list):
749
+ continue
750
+ for event in events:
751
+ if not isinstance(event, dict):
752
+ continue
753
+ author = event.get("author", "")
754
+ if not isinstance(author, str) or author.lower() != "user":
755
+ continue
756
+ content = event.get("content")
757
+ if not content or not isinstance(content, dict):
758
+ continue
759
+ parts = content.get("parts", [])
760
+ if not isinstance(parts, list):
761
+ continue
762
+ for part in parts:
763
+ if not isinstance(part, dict):
764
+ continue
765
+ text = str(part.get("text", "")).strip()
766
+ if text:
767
+ if len(text) > 150:
768
+ return text[:150] + "..."
769
+ return text
770
+ return None
771
+
772
+
773
+ def _truncate_scenario(text: str, max_len: int = 150) -> str:
774
+ """Truncates a scenario preview to max_len characters."""
775
+ text = text.strip()
776
+ if len(text) > max_len:
777
+ return text[:max_len] + "..."
778
+ return text
779
+
780
+
781
+ def _build_scenario_preview_list(
782
+ eval_result: types.EvaluationResult,
783
+ ) -> list[Optional[str]]:
784
+ """Builds an ordered list of scenario previews from the EvaluationResult.
785
+
786
+ Returns one scenario preview per eval_case_result, in the same order as
787
+ eval_case_results. This extracts the first user message from the original
788
+ SDK EvaluationResult (via eval_cases or DataFrame), rather than relying
789
+ on the API echo-back which may not preserve the request data.
790
+
791
+ Extraction priority per eval case:
792
+ 1. eval_case.agent_data → first user message in turns
793
+ 2. eval_case.user_scenario.starting_prompt
794
+ 3. eval_case.prompt → text content
795
+ 4. DataFrame agent_data column → first user message
796
+ 5. DataFrame starting_prompt column
797
+ """
798
+ eval_dataset = eval_result.evaluation_dataset
799
+ eval_cases: list[Any] = []
800
+ if isinstance(eval_dataset, list) and eval_dataset:
801
+ eval_cases = getv(eval_dataset[0], ["eval_cases"]) or []
802
+
803
+ eval_case_results = eval_result.eval_case_results or []
804
+ scenarios: list[Optional[str]] = []
805
+
806
+ for case_result in eval_case_results:
807
+ case_idx = case_result.eval_case_index or 0
808
+ scenario: Optional[str] = None
809
+
810
+ eval_case = None
811
+ if 0 <= case_idx < len(eval_cases):
812
+ eval_case = eval_cases[case_idx]
813
+
814
+ if eval_case:
815
+ # 1. Try agent_data (populated after run_inference)
816
+ agent_data = getv(eval_case, ["agent_data"])
817
+ if agent_data:
818
+ scenario = _extract_scenario_from_agent_data(agent_data)
819
+
820
+ # 2. Try user_scenario.starting_prompt (from
821
+ # generate_conversation_scenarios)
822
+ if scenario is None:
823
+ user_scenario = getv(eval_case, ["user_scenario"])
824
+ if user_scenario:
825
+ starting_prompt = getv(user_scenario, ["starting_prompt"])
826
+ if starting_prompt and isinstance(starting_prompt, str):
827
+ scenario = _truncate_scenario(starting_prompt)
828
+
829
+ # 3. Try prompt text
830
+ if scenario is None:
831
+ prompt = getv(eval_case, ["prompt"])
832
+ if prompt:
833
+ from . import _evals_data_converters
834
+
835
+ text = _evals_data_converters._get_content_text(prompt)
836
+ if text:
837
+ scenario = _truncate_scenario(str(text))
838
+
839
+ # 4. Fallback: extract agent_data from DataFrame
840
+ if scenario is None and eval_dataset:
841
+ df_agent_data = _transformers._extract_agent_data_from_df(
842
+ eval_dataset, case_idx
843
+ )
844
+ if df_agent_data is not None:
845
+ scenario = _extract_scenario_from_agent_data(df_agent_data)
846
+
847
+ # 5. Fallback: extract starting_prompt from DataFrame
848
+ if scenario is None and eval_dataset:
849
+ ds = eval_dataset[0] if isinstance(eval_dataset, list) else eval_dataset
850
+ df = getv(ds, ["eval_dataset_df"])
851
+ if df is not None and hasattr(df, "iloc"):
852
+ if 0 <= case_idx < len(df):
853
+ row = df.iloc[case_idx]
854
+ sp = row.get("starting_prompt")
855
+ if sp and isinstance(sp, str) and sp.strip():
856
+ scenario = _truncate_scenario(sp)
857
+
858
+ scenarios.append(scenario)
859
+
860
+ return scenarios
861
+
862
+
863
+ def _enrich_loss_response_with_rubric_descriptions(
864
+ response: types.GenerateLossClustersResponse,
865
+ eval_result: types.EvaluationResult,
866
+ ) -> None:
867
+ """Enriches loss response with rubric descriptions and scenario previews.
868
+
869
+ Rubric descriptions and scenario previews are extracted from the original
870
+ SDK EvaluationResult object, because the API echo-back in
871
+ LossExample.evaluation_result may not preserve all request data (e.g.,
872
+ agent_data turns with user messages).
873
+ """
874
+ rubric_map = _build_rubric_description_map(eval_result)
875
+ scenario_list = _build_scenario_preview_list(eval_result)
876
+ logger.debug(
877
+ "Enriching loss response: %d scenarios extracted, %d rubric" " descriptions",
878
+ sum(1 for s in scenario_list if s),
879
+ len(rubric_map),
880
+ )
881
+ for result in response.results or []:
882
+ for cluster in result.clusters or []:
883
+ for example in cluster.examples or []:
884
+ if example.evaluation_result is None:
885
+ example.evaluation_result = {}
886
+ if rubric_map:
887
+ example.evaluation_result["rubric_descriptions"] = rubric_map
888
+ # Try extracting scenario from the API echo-back first
889
+ if "scenario_preview" not in example.evaluation_result:
890
+ scenario = _extract_scenario_preview_from_dict(
891
+ example.evaluation_result
892
+ )
893
+ if scenario:
894
+ example.evaluation_result["scenario_preview"] = scenario
895
+ # Fallback: match against scenarios from original eval_result
896
+ if "scenario_preview" not in example.evaluation_result:
897
+ if scenario_list:
898
+ for s in scenario_list:
899
+ if s:
900
+ example.evaluation_result["scenario_preview"] = s
901
+ break
902
+
903
+
904
+ def _poll_operation(
905
+ api_client: BaseApiClient,
906
+ operation: types.GenerateLossClustersOperation,
907
+ poll_interval_seconds: float = 5.0,
908
+ ) -> types.GenerateLossClustersOperation:
909
+ """Polls a long-running operation until completion.
910
+
911
+ Args:
912
+ api_client: The API client to use for polling.
913
+ operation: The initial operation returned from the API call.
914
+ poll_interval_seconds: Time between polls.
915
+
916
+ Returns:
917
+ The completed operation.
918
+ """
919
+ if operation.done:
920
+ return operation
921
+ start_time = time.time()
922
+ while True:
923
+ response = api_client.request("get", operation.name, {}, None)
924
+ response_dict = {} if not response.body else json.loads(response.body)
925
+ polled = types.GenerateLossClustersOperation._from_response(
926
+ response=response_dict, kwargs={}
927
+ )
928
+ if polled.done:
929
+ return polled
930
+ elapsed = int(time.time() - start_time)
931
+ logger.info(
932
+ "Loss analysis operation still running... Elapsed time: %d seconds",
933
+ elapsed,
934
+ )
935
+ time.sleep(poll_interval_seconds)
936
+
937
+
938
+ async def _poll_operation_async(
939
+ api_client: BaseApiClient,
940
+ operation: types.GenerateLossClustersOperation,
941
+ poll_interval_seconds: float = 5.0,
942
+ ) -> types.GenerateLossClustersOperation:
943
+ """Polls a long-running operation until completion (async).
944
+
945
+ Args:
946
+ api_client: The API client to use for polling.
947
+ operation: The initial operation returned from the API call.
948
+ poll_interval_seconds: Time between polls.
949
+
950
+ Returns:
951
+ The completed operation.
952
+ """
953
+ if operation.done:
954
+ return operation
955
+ start_time = time.time()
956
+ while True:
957
+ response = await api_client.async_request("get", operation.name, {}, None)
958
+ response_dict = {} if not response.body else json.loads(response.body)
959
+ polled = types.GenerateLossClustersOperation._from_response(
960
+ response=response_dict, kwargs={}
961
+ )
962
+ if polled.done:
963
+ return polled
964
+ elapsed = int(time.time() - start_time)
965
+ logger.info(
966
+ "Loss analysis operation still running... Elapsed time: %d seconds",
967
+ elapsed,
968
+ )
969
+ await asyncio.sleep(poll_interval_seconds)
970
+
971
+
972
+ def _validate_dataset_agent_data(
973
+ dataset: types.EvaluationDataset,
974
+ inference_configs: Optional[dict[str, Any]] = None,
975
+ ) -> None:
976
+ """Validates agent_data in the EvaluationDataset.
977
+
978
+ Checks that agent_data matches the expected AgentData type and that
979
+ 'agents' are not defined in both the dataset's agent_data and inference_configs.
980
+ """
981
+ has_inference_agent_configs = False
982
+ if inference_configs:
983
+ for cand_config in inference_configs.values():
984
+ if isinstance(cand_config, dict) and cand_config.get("agent_configs"):
985
+ has_inference_agent_configs = True
986
+ elif hasattr(cand_config, "agent_configs") and cand_config.agent_configs:
987
+ has_inference_agent_configs = True
988
+
989
+ def _validate_single_agent_data(agent_data_val: Any, identifier: str) -> None:
990
+
991
+ if not agent_data_val:
992
+ return
993
+
994
+ agent_data_obj = None
995
+ if isinstance(agent_data_val, str):
996
+ try:
997
+ agent_data_val = json.loads(agent_data_val)
998
+ if "error" in agent_data_val:
999
+ return
1000
+ agent_data_obj = types.evals.AgentData.model_validate(agent_data_val)
1001
+ except json.JSONDecodeError as e:
1002
+ raise ValueError(
1003
+ f"{identifier}: 'agent_data' is not valid JSON: {e}"
1004
+ ) from e
1005
+ elif isinstance(agent_data_val, dict) and "error" in agent_data_val:
1006
+ return
1007
+ elif isinstance(agent_data_val, dict):
1008
+ try:
1009
+ agent_data_obj = types.evals.AgentData.model_validate(agent_data_val)
1010
+ except Exception as e:
1011
+ raise ValueError(
1012
+ f"{identifier}: 'agent_data' "
1013
+ f"is inconsistent with AgentData type: {e}"
1014
+ ) from e
1015
+ elif isinstance(agent_data_val, types.evals.AgentData):
1016
+ agent_data_obj = agent_data_val
1017
+ else:
1018
+ raise ValueError(
1019
+ f"{identifier}: 'agent_data' is inconsistent with AgentData type. "
1020
+ f"Got {type(agent_data_val)}"
1021
+ )
1022
+
1023
+ if agent_data_obj and agent_data_obj.agents and has_inference_agent_configs:
1024
+ raise ValueError(
1025
+ f"{identifier}: Cannot provide 'agents' in the dataset's 'agent_data' "
1026
+ "and 'agent_configs' in inference_configs at the same time."
1027
+ )
1028
+
1029
+ if (
1030
+ dataset.eval_dataset_df is not None
1031
+ and "agent_data" in dataset.eval_dataset_df.columns
1032
+ ):
1033
+ for idx, row in dataset.eval_dataset_df.iterrows():
1034
+ _validate_single_agent_data(row.get("agent_data"), f"Row {idx}")
1035
+
1036
+ if dataset.eval_cases:
1037
+ for idx, eval_case in enumerate(dataset.eval_cases):
1038
+ agent_data = None
1039
+ if isinstance(eval_case, dict):
1040
+ agent_data = eval_case.get("agent_data", None)
1041
+ elif hasattr(eval_case, "agent_data"):
1042
+ agent_data = eval_case.agent_data
1043
+ _validate_single_agent_data(agent_data, f"EvalCase {idx}")