Skip to content

Commit f589513

Browse files
committed
[LEADS-443] OPENAI_API_KEY environmental variable failure
1 parent a625cab commit f589513

5 files changed

Lines changed: 292 additions & 9 deletions

File tree

src/lightspeed_evaluation/core/embedding/manager.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
"""Embedding Manager - Generic embedding configuration, validation, and parameter provider."""
22

3+
import logging
4+
35
from lightspeed_evaluation.core.models import EmbeddingConfig, SystemConfig
46
from lightspeed_evaluation.core.system.env_validator import validate_provider_env
57

8+
logger = logging.getLogger(__name__)
9+
610

711
class EmbeddingError(Exception):
812
"""Embedding config errors."""
@@ -31,16 +35,36 @@ def check_huggingface_available() -> None:
3135
) from e
3236

3337

34-
class EmbeddingManager: # pylint: disable=too-few-public-methods
35-
"""Generic Embedding Manager."""
38+
class EmbeddingManager:
39+
"""Generic Embedding Manager with lazy validation.
40+
41+
Validation of provider environment variables is deferred until first actual
42+
use. This allows the evaluation pipeline to start without requiring embedding
43+
provider credentials when no metric relying on embeddings is configured.
44+
"""
3645

3746
def __init__(self, config: EmbeddingConfig):
38-
"""Initialize with validated environment and constructed model name."""
47+
"""Initialize with config. Validation is deferred until ensure_ready() is called."""
3948
self.config = config
49+
self._validated = False
50+
51+
def ensure_ready(self) -> None:
52+
"""Validate provider config and environment variables on first use.
53+
54+
This method is idempotent - subsequent calls after successful validation
55+
are no-ops. Should be called before accessing embedding functionality.
56+
57+
Raises:
58+
EmbeddingError: If provider is unsupported or env vars are missing.
59+
"""
60+
if self._validated:
61+
return
4062
self._validate_config()
41-
print(
42-
f"""
43-
✅ Embedding Manager: {self.config.provider} -- {self.config.model} {self.config.provider_kwargs}"""
63+
self._validated = True
64+
logger.info(
65+
"Embedding Manager ready: %s -- %s",
66+
self.config.provider,
67+
self.config.model,
4468
)
4569

4670
def _validate_config(self) -> None:

src/lightspeed_evaluation/core/embedding/ragas.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ def __init__(self, embedding_manager: EmbeddingManager):
1919
2020
Args:
2121
embedding_manager: Pre-configured EmbeddingManager with validated parameters
22+
23+
Raises:
24+
EmbeddingError: If provider environment variables are not configured.
25+
ConfigurationError: If embedding provider is unknown.
2226
"""
27+
embedding_manager.ensure_ready()
2328
self.config = embedding_manager.config
2429

2530
# Map provider names to litellm format

src/lightspeed_evaluation/core/metrics/ragas.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
Faithfulness,
1717
)
1818

19-
from lightspeed_evaluation.core.embedding.manager import EmbeddingManager
19+
from lightspeed_evaluation.core.embedding.manager import (
20+
EmbeddingError,
21+
EmbeddingManager,
22+
)
2023
from lightspeed_evaluation.core.embedding.ragas import RagasEmbeddingManager
2124
from lightspeed_evaluation.core.llm.litellm_patch import litellm_state_lock
2225
from lightspeed_evaluation.core.llm.manager import LLMManager
2326
from lightspeed_evaluation.core.llm.ragas import RagasLLMManager
2427
from lightspeed_evaluation.core.models import EvaluationScope, TurnData
28+
from lightspeed_evaluation.core.system.exceptions import EvaluationError
2529

2630

2731
def _clamp_score(score: float) -> float:
@@ -70,7 +74,9 @@ def __init__(self, llm_manager: LLMManager, embedding_manager: EmbeddingManager)
7074

7175
# Create Ragas LLM Manager for metric configuration
7276
self.llm_manager = RagasLLMManager(llm_manager)
73-
self.embedding_manager = RagasEmbeddingManager(embedding_manager)
77+
# Store base embedding manager for lazy initialization of RagasEmbeddingManager
78+
self._embedding_manager = embedding_manager
79+
self._ragas_embedding_manager: Optional[RagasEmbeddingManager] = None
7480

7581
self.supported_metrics = {
7682
# Response evaluation metrics
@@ -85,6 +91,19 @@ def __init__(self, llm_manager: LLMManager, embedding_manager: EmbeddingManager)
8591
),
8692
}
8793

94+
@property
95+
def embedding_manager(self) -> RagasEmbeddingManager:
96+
"""Lazily initialize RagasEmbeddingManager on first access.
97+
98+
This defers embedding provider validation until a metric that actually
99+
requires embeddings is evaluated (e.g., response_relevancy).
100+
"""
101+
if self._ragas_embedding_manager is None:
102+
self._ragas_embedding_manager = RagasEmbeddingManager(
103+
self._embedding_manager
104+
)
105+
return self._ragas_embedding_manager
106+
88107
def _extract_turn_data(
89108
self, turn_data: Optional[TurnData]
90109
) -> tuple[str, str, list[str]]:
@@ -128,7 +147,14 @@ def evaluate(
128147
f"(network/LLM timeout): {str(e)}"
129148
)
130149
return None, err_msg
131-
except (RuntimeError, ValueError, TypeError, ImportError) as e:
150+
except (
151+
EvaluationError,
152+
EmbeddingError,
153+
RuntimeError,
154+
ValueError,
155+
TypeError,
156+
ImportError,
157+
) as e:
132158
return None, f"Ragas {metric_name} evaluation failed: {str(e)}"
133159

134160
if result[0] is not None and math.isnan(result[0]):
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# pylint: disable=protected-access
2+
3+
"""Unit tests for EmbeddingManager."""
4+
5+
import pytest
6+
from pytest_mock import MockerFixture
7+
8+
from lightspeed_evaluation.core.embedding.manager import (
9+
EmbeddingError,
10+
EmbeddingManager,
11+
)
12+
from lightspeed_evaluation.core.models import EmbeddingConfig
13+
from lightspeed_evaluation.core.system.exceptions import LLMError
14+
15+
16+
class TestEnsureReadyIdempotency:
17+
"""Test that ensure_ready() is idempotent."""
18+
19+
def test_second_call_is_noop(self, mocker: MockerFixture) -> None:
20+
"""Calling ensure_ready() twice should only validate once."""
21+
mocker.patch(
22+
"lightspeed_evaluation.core.embedding.manager.validate_provider_env"
23+
)
24+
manager = EmbeddingManager(EmbeddingConfig(provider="openai"))
25+
26+
manager.ensure_ready()
27+
manager.ensure_ready()
28+
29+
validate_mock = mocker.patch.object(manager, "_validate_config")
30+
manager.ensure_ready()
31+
validate_mock.assert_not_called()
32+
33+
def test_not_validated_after_failure(self, mocker: MockerFixture) -> None:
34+
"""_validated stays False when validation raises."""
35+
mocker.patch(
36+
"lightspeed_evaluation.core.embedding.manager.validate_provider_env",
37+
side_effect=EmbeddingError("env var missing"),
38+
)
39+
manager = EmbeddingManager(EmbeddingConfig(provider="openai"))
40+
41+
with pytest.raises(EmbeddingError):
42+
manager.ensure_ready()
43+
44+
assert not manager._validated
45+
46+
47+
class TestEnsureReadyErrors:
48+
"""Test ensure_ready() error cases."""
49+
50+
def test_unsupported_provider_raises_embedding_error(
51+
self, mocker: MockerFixture
52+
) -> None:
53+
"""Unsupported provider should raise EmbeddingError.
54+
55+
Pydantic validates allowed providers at construction, so we bypass
56+
it to exercise the defensive guard in _validate_config().
57+
"""
58+
config = mocker.MagicMock()
59+
config.provider = "unsupported_provider"
60+
manager = EmbeddingManager(config)
61+
62+
with pytest.raises(EmbeddingError, match="Unsupported embedding provider"):
63+
manager.ensure_ready()
64+
65+
def test_missing_env_vars_raises(self, mocker: MockerFixture) -> None:
66+
"""Missing env vars should propagate the error from validate_provider_env."""
67+
mocker.patch(
68+
"lightspeed_evaluation.core.embedding.manager.validate_provider_env",
69+
side_effect=LLMError("OPENAI_API_KEY not set"),
70+
)
71+
manager = EmbeddingManager(EmbeddingConfig(provider="openai"))
72+
73+
with pytest.raises(LLMError, match="OPENAI_API_KEY not set"):
74+
manager.ensure_ready()
75+
76+
def test_huggingface_missing_deps_raises(self, mocker: MockerFixture) -> None:
77+
"""Missing sentence-transformers should raise EmbeddingError."""
78+
mocker.patch(
79+
"lightspeed_evaluation.core.embedding.manager.check_huggingface_available",
80+
side_effect=EmbeddingError("requires sentence-transformers"),
81+
)
82+
manager = EmbeddingManager(EmbeddingConfig(provider="huggingface"))
83+
84+
with pytest.raises(EmbeddingError, match="sentence-transformers"):
85+
manager.ensure_ready()
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# pylint: disable=redefined-outer-name, too-many-arguments, too-many-positional-arguments
2+
3+
"""Unit tests for RagasMetrics."""
4+
5+
from typing import Any
6+
7+
import pytest
8+
from pytest_mock import MockerFixture
9+
10+
from lightspeed_evaluation.core.embedding.manager import (
11+
EmbeddingError,
12+
EmbeddingManager,
13+
)
14+
from lightspeed_evaluation.core.metrics.ragas import RagasMetrics
15+
from lightspeed_evaluation.core.models import EmbeddingConfig, EvaluationScope, TurnData
16+
from lightspeed_evaluation.core.system.exceptions import (
17+
ConfigurationError,
18+
EvaluationError,
19+
)
20+
21+
22+
@pytest.fixture
23+
def mock_ragas_deps(mocker: MockerFixture) -> dict[str, Any]:
24+
"""Mock all heavy dependencies needed to construct RagasMetrics."""
25+
mock_llm_manager = mocker.MagicMock()
26+
mock_llm_config = mocker.MagicMock()
27+
mock_llm_config.cache_enabled = False
28+
mock_llm_manager.get_config.return_value = mock_llm_config
29+
30+
mock_embedding_manager = mocker.MagicMock(spec=EmbeddingManager)
31+
mock_embedding_manager.config = EmbeddingConfig(
32+
provider="openai", model="text-embedding-3-small", cache_enabled=False
33+
)
34+
35+
mocker.patch("lightspeed_evaluation.core.metrics.ragas.RagasLLMManager")
36+
37+
return {
38+
"llm_manager": mock_llm_manager,
39+
"embedding_manager": mock_embedding_manager,
40+
}
41+
42+
43+
@pytest.fixture
44+
def ragas_metrics(mock_ragas_deps: dict[str, Any]) -> RagasMetrics:
45+
"""Create RagasMetrics with mocked dependencies."""
46+
return RagasMetrics(**mock_ragas_deps)
47+
48+
49+
@pytest.fixture
50+
def turn_scope() -> EvaluationScope:
51+
"""Create a turn-level evaluation scope."""
52+
return EvaluationScope(
53+
turn_idx=0,
54+
turn_data=TurnData(
55+
turn_id="t1",
56+
query="What is Python?",
57+
response="A programming language.",
58+
expected_response="A programming language.",
59+
),
60+
is_conversation=False,
61+
)
62+
63+
64+
class TestLazyEmbeddingManagerProperty:
65+
"""Test lazy initialization of the embedding_manager property."""
66+
67+
def test_initialized_on_first_access(
68+
self, ragas_metrics: RagasMetrics, mocker: MockerFixture
69+
) -> None:
70+
"""First property access should create and return RagasEmbeddingManager."""
71+
mock_cls = mocker.patch(
72+
"lightspeed_evaluation.core.metrics.ragas.RagasEmbeddingManager",
73+
)
74+
75+
result = ragas_metrics.embedding_manager
76+
77+
assert result is mock_cls.return_value
78+
mock_cls.assert_called_once()
79+
80+
def test_cached_after_first_access(
81+
self, ragas_metrics: RagasMetrics, mocker: MockerFixture
82+
) -> None:
83+
"""Subsequent accesses should return the cached instance."""
84+
mock_cls = mocker.patch(
85+
"lightspeed_evaluation.core.metrics.ragas.RagasEmbeddingManager",
86+
)
87+
88+
first = ragas_metrics.embedding_manager
89+
second = ragas_metrics.embedding_manager
90+
91+
assert first is second
92+
mock_cls.assert_called_once()
93+
94+
95+
class TestEvaluateExceptionHandling:
96+
"""Test that evaluate() catches the expected exception types."""
97+
98+
@pytest.mark.parametrize(
99+
"exception_class,exception_msg",
100+
[
101+
(EvaluationError, "base evaluation error"),
102+
(ConfigurationError, "unknown provider xyz"),
103+
(EmbeddingError, "unsupported embedding provider"),
104+
(RuntimeError, "unexpected runtime failure"),
105+
(ValueError, "invalid value"),
106+
(TypeError, "type mismatch"),
107+
(ImportError, "missing module"),
108+
],
109+
)
110+
def test_catches_exception_gracefully(
111+
self,
112+
ragas_metrics: RagasMetrics,
113+
turn_scope: EvaluationScope,
114+
mocker: MockerFixture,
115+
exception_class: type,
116+
exception_msg: str,
117+
) -> None:
118+
"""evaluate() should return (None, error_message) for caught exceptions."""
119+
ragas_metrics.supported_metrics["faithfulness"] = mocker.MagicMock(
120+
side_effect=exception_class(exception_msg)
121+
)
122+
123+
score, reason = ragas_metrics.evaluate(
124+
"faithfulness", mocker.MagicMock(), turn_scope
125+
)
126+
127+
assert score is None
128+
assert exception_msg in reason
129+
assert "evaluation failed" in reason
130+
131+
def test_unsupported_metric_returns_none(
132+
self,
133+
ragas_metrics: RagasMetrics,
134+
turn_scope: EvaluationScope,
135+
mocker: MockerFixture,
136+
) -> None:
137+
"""Unsupported metric name should return (None, message) without raising."""
138+
score, reason = ragas_metrics.evaluate(
139+
"nonexistent_metric", mocker.MagicMock(), turn_scope
140+
)
141+
142+
assert score is None
143+
assert "Unsupported Ragas metric" in reason

0 commit comments

Comments
 (0)