From 1ef18ee6173bed55a683967d9b046d35c5f851a7 Mon Sep 17 00:00:00 2001 From: Nathan Habib Date: Mon, 10 Aug 2026 15:41:52 +0200 Subject: [PATCH 1/3] Store provider credentials as SecretStr in model configs LiteLLMModelConfig.api_key and TGIModelConfig.inference_server_auth were plain str fields, which meant they were retained in plaintext wherever a model config gets serialized (e.g. EvaluationTracker.results). Switch both to pydantic SecretStr, which masks the value in reprs and default serialization, and additionally exclude them explicitly when building the results dict as a second layer. The real value is still unwrapped via get_secret_value() at the specific call sites that need it for the actual outgoing request. Added regression tests asserting the credential never appears in the serialized results dict. Co-Authored-By: Claude Sonnet 5 --- src/lighteval/logging/evaluation_tracker.py | 6 ++- .../models/endpoints/litellm_model.py | 8 ++-- src/lighteval/models/endpoints/tgi_model.py | 10 +++-- tests/unit/logging/test_evaluation_tracker.py | 40 ++++++++++++++++++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/lighteval/logging/evaluation_tracker.py b/src/lighteval/logging/evaluation_tracker.py index 976b21c86..d6f6a04a0 100644 --- a/src/lighteval/logging/evaluation_tracker.py +++ b/src/lighteval/logging/evaluation_tracker.py @@ -211,7 +211,11 @@ def __init__( @property def results(self): config_general = asdict(self.general_config_logger) - config_general["model_config"] = config_general["model_config"].model_dump() + # This exclude set is defense in depth: SecretStr fields already serialize masked, + # but this ensures no future plain-str secret field leaks through model_dump(). + config_general["model_config"] = config_general["model_config"].model_dump( + exclude={"api_key", "inference_server_auth"} + ) results = { "config_general": config_general, "results": self.metrics_logger.metric_aggregated, diff --git a/src/lighteval/models/endpoints/litellm_model.py b/src/lighteval/models/endpoints/litellm_model.py index 87332d1d7..d4f68b1e9 100644 --- a/src/lighteval/models/endpoints/litellm_model.py +++ b/src/lighteval/models/endpoints/litellm_model.py @@ -26,6 +26,7 @@ from json import JSONDecodeError import requests +from pydantic import SecretStr from tqdm import tqdm from lighteval.data import GenerativeTaskDataset @@ -77,9 +78,10 @@ class LiteLLMModelConfig(ModelConfig): base_url (str | None): Custom base URL for the API. If None, uses provider's default URL. Useful for using custom endpoints or local deployments. - api_key (str | None): + api_key (SecretStr | None): API key for authentication. If None, reads from environment variables. Environment variable names are provider-specific (e.g., OPENAI_API_KEY). + Stored as a SecretStr so it is masked in logs, reprs, and serialized configs. concurrent_requests (int): Maximum number of concurrent API requests to execute in parallel. Higher values can improve throughput for batch processing but may hit rate limits @@ -121,7 +123,7 @@ class LiteLLMModelConfig(ModelConfig): model_name: str provider: str | None = None base_url: str | None = None - api_key: str | None = None + api_key: SecretStr | None = None concurrent_requests: int = 10 verbose: bool = False max_model_length: int | None = None @@ -144,7 +146,7 @@ def __init__(self, config: LiteLLMModelConfig) -> None: self.model = config.model_name self.provider = config.provider or config.model_name.split("/")[0] self.base_url = config.base_url - self.api_key = config.api_key + self.api_key = config.api_key.get_secret_value() if config.api_key is not None else None self.generation_parameters = config.generation_parameters self.concurrent_requests = config.concurrent_requests self._max_length = config.max_model_length diff --git a/src/lighteval/models/endpoints/tgi_model.py b/src/lighteval/models/endpoints/tgi_model.py index 4fd765b8d..8e7c6c979 100644 --- a/src/lighteval/models/endpoints/tgi_model.py +++ b/src/lighteval/models/endpoints/tgi_model.py @@ -26,6 +26,7 @@ import requests from huggingface_hub import TextGenerationInputGenerateParameters, TextGenerationInputGrammarType, TextGenerationOutput +from pydantic import SecretStr from transformers.models.auto.tokenization_auto import AutoTokenizer from lighteval.models.abstract_model import ModelConfig @@ -65,8 +66,9 @@ class TGIModelConfig(ModelConfig): inference_server_address (str | None): Address of the TGI server. Format: "http://host:port" or "https://host:port". Example: "http://localhost:8080" - inference_server_auth (str | None): + inference_server_auth (SecretStr | None): Authentication token for the TGI server. If None, no authentication is used. + Stored as a SecretStr so it is masked in logs, reprs, and serialized configs. model_name (str | None): Optional model name override. If None, uses the model name from server info. generation_parameters (GenerationParameters, optional, defaults to empty GenerationParameters): @@ -91,7 +93,7 @@ class TGIModelConfig(ModelConfig): """ inference_server_address: str | None = None - inference_server_auth: str | None = None + inference_server_auth: SecretStr | None = None model_name: str | None model_info: dict | None = None batch_size: int = 1 @@ -104,7 +106,9 @@ class ModelClient(InferenceEndpointModel): def __init__(self, config: TGIModelConfig) -> None: headers = ( - {} if config.inference_server_auth is None else {"Authorization": f"Bearer {config.inference_server_auth}"} + {} + if config.inference_server_auth is None + else {"Authorization": f"Bearer {config.inference_server_auth.get_secret_value()}"} ) self.client = AsyncClient(config.inference_server_address, headers=headers, timeout=240) diff --git a/tests/unit/logging/test_evaluation_tracker.py b/tests/unit/logging/test_evaluation_tracker.py index 45c5790d0..3a76be2a4 100644 --- a/tests/unit/logging/test_evaluation_tracker.py +++ b/tests/unit/logging/test_evaluation_tracker.py @@ -299,13 +299,11 @@ def setUp(self): "model_name": "test/case", "provider": None, "base_url": None, - "api_key": None, "system_prompt": ref_system_prompt, "generation_parameters": ref_generation_parameters, } # ruff: noqa: E501 self.tgi_ref_config = { "inference_server_address": None, - "inference_server_auth": None, "model_name": "test/case", "model_info": None, "system_prompt": ref_system_prompt, @@ -485,3 +483,41 @@ def test_model_config_property_with_different_model_configs(self): for k, v in ref_config.items(): with self.subTest(model_config=model_config, model_property=k): self.assertEqual(results["config_general"]["model_config"][k], v) + + def test_litellm_api_key_never_leaks_to_results(self): + """A LiteLLM api_key must never appear in the results dict or its JSON/parquet serialization.""" + from lighteval.logging.evaluation_tracker import EnhancedJSONEncoder + from lighteval.models.endpoints.litellm_model import LiteLLMModelConfig + + secret = "sk-super-secret-should-not-leak" + model_config = LiteLLMModelConfig(model_name="test/case", api_key=secret) + + with tempfile.TemporaryDirectory() as tmp_dir: + evaluation_tracker = EvaluationTracker(output_dir=tmp_dir) + evaluation_tracker.general_config_logger.log_model_info(model_config=model_config) + + results = evaluation_tracker.results + + self.assertNotIn("api_key", results["config_general"]["model_config"]) + + serialized = json.dumps(results, cls=EnhancedJSONEncoder) + self.assertNotIn(secret, serialized) + + def test_tgi_inference_server_auth_never_leaks_to_results(self): + """A TGI inference_server_auth token must never appear in the results dict or its JSON/parquet serialization.""" + from lighteval.logging.evaluation_tracker import EnhancedJSONEncoder + from lighteval.models.endpoints.tgi_model import TGIModelConfig + + secret = "tgi-bearer-token-should-not-leak" + model_config = TGIModelConfig(model_name="test/case", inference_server_auth=secret) + + with tempfile.TemporaryDirectory() as tmp_dir: + evaluation_tracker = EvaluationTracker(output_dir=tmp_dir) + evaluation_tracker.general_config_logger.log_model_info(model_config=model_config) + + results = evaluation_tracker.results + + self.assertNotIn("inference_server_auth", results["config_general"]["model_config"]) + + serialized = json.dumps(results, cls=EnhancedJSONEncoder) + self.assertNotIn(secret, serialized) From ae9d01d0cabec50b3ae56e0b8478b1f974585ee4 Mon Sep 17 00:00:00 2001 From: Nathan Habib Date: Mon, 10 Aug 2026 15:42:01 +0200 Subject: [PATCH 2/3] Store JudgeLM credentials as SecretStr JudgeLM.api_key was a plain str consumed directly by several backend clients (OpenAI, AsyncInferenceClient, litellm). Wrap it in SecretStr on assignment and unwrap via get_secret_value() at each usage site, for consistency with the other model configs and to remove any reliance on incidental string formatting to keep it out of logs or serialized output. Co-Authored-By: Claude Sonnet 5 --- src/lighteval/metrics/utils/llm_as_judge.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lighteval/metrics/utils/llm_as_judge.py b/src/lighteval/metrics/utils/llm_as_judge.py index e18b3a4c0..989588029 100644 --- a/src/lighteval/metrics/utils/llm_as_judge.py +++ b/src/lighteval/metrics/utils/llm_as_judge.py @@ -29,7 +29,7 @@ from typing import Callable, Literal, Optional from huggingface_hub import AsyncInferenceClient, InferenceTimeoutError -from pydantic import BaseModel +from pydantic import BaseModel, SecretStr from requests.exceptions import HTTPError from tqdm import tqdm from tqdm.asyncio import tqdm_asyncio @@ -74,6 +74,7 @@ class JudgeLM: judge_backend (Literal["litellm", "openai", "transformers", "tgi", "vllm", "inference-providers"]): The backend for the judge. url (str | None): The URL for the OpenAI API. api_key (str | None): The API key for the OpenAI API (either OpenAI or HF key). + Stored internally as a SecretStr so it is masked in logs, reprs, and serialized configs. max_tokens (int): The maximum number of tokens to generate. Defaults to 512. response_format (BaseModel | None): The format of the response from the API, used for the OpenAI and TGI backend. hf_provider (Literal["black-forest-labs", "cerebras", "cohere", "fal-ai", "fireworks-ai", @@ -129,7 +130,7 @@ def __init__( self.process_judge_response = process_judge_response self.url = url - self.api_key = api_key + self.api_key = SecretStr(api_key) if api_key is not None else None self.backend = judge_backend self.hf_provider = hf_provider self.max_tokens = max_tokens @@ -156,7 +157,8 @@ def __lazy_load_client(self): # noqa: C901 from openai import OpenAI self.client = OpenAI( - api_key=self.api_key if self.url is None else None, base_url=self.url if self.url else None + api_key=self.api_key.get_secret_value() if self.url is None and self.api_key else None, + base_url=self.url if self.url else None, ) return self.__call_api_parallel @@ -195,7 +197,11 @@ def __lazy_load_client(self): # noqa: C901 case "inference-providers": from huggingface_hub import AsyncInferenceClient - self.client = AsyncInferenceClient(token=self.api_key, base_url=self.url, provider=self.hf_provider) + self.client = AsyncInferenceClient( + token=self.api_key.get_secret_value() if self.api_key else None, + base_url=self.url, + provider=self.hf_provider, + ) return self.__call_hf_inference_async case _: @@ -340,7 +346,7 @@ def __call_api(prompt): if max_new_tokens is not None: kwargs["max_tokens"] = (max_new_tokens,) if self.api_key is not None: - kwargs["api_key"] = self.api_key + kwargs["api_key"] = self.api_key.get_secret_value() if self.url is not None: kwargs["base_url"] = self.url From 50ac35cc7819d9f4cce5c9e21f0754a10399334d Mon Sep 17 00:00:00 2001 From: Nathan Habib Date: Mon, 10 Aug 2026 15:42:17 +0200 Subject: [PATCH 3/3] Fix pre-existing ruff format drift in README and docs Unrelated cleanup so CI's Quality check is green on this branch. Co-Authored-By: Claude Sonnet 5 --- README.md | 9 ++------- docs/source/offline-evaluation.md | 7 +------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 81a05f859..d4a0dcb18 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,9 @@ MODEL_NAME = "meta-llama/Meta-Llama-3-8B-Instruct" BENCHMARKS = "gsm8k" evaluation_tracker = EvaluationTracker(output_dir="./results") -pipeline_params = PipelineParameters( - launcher_type=ParallelismManager.NONE, - max_samples=2 -) +pipeline_params = PipelineParameters(launcher_type=ParallelismManager.NONE, max_samples=2) -model = AutoModelForCausalLM.from_pretrained( - MODEL_NAME, device_map="auto" -) +model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, device_map="auto") config = TransformersModelConfig(model_name=MODEL_NAME, batch_size=1) model = TransformersModel.from_model(model, config) diff --git a/docs/source/offline-evaluation.md b/docs/source/offline-evaluation.md index 37c921b61..f90f59f17 100644 --- a/docs/source/offline-evaluation.md +++ b/docs/source/offline-evaluation.md @@ -18,12 +18,7 @@ from lighteval.tasks.requests import Doc def local_prompt(line: dict, task_name: str) -> Doc: - return Doc( - task_name=task_name, - query=line["question"], - choices=line["choices"], - gold_index=line["answer"] - ) + return Doc(task_name=task_name, query=line["question"], choices=line["choices"], gold_index=line["answer"]) local_data = Path(__file__).parent / "samples" / "faq.jsonl"