Skip to content

Commit 4c5a15a

Browse files
authored
Merge pull request #336 from Winston-503/main
feat: ${VAR} for API key configuration
2 parents 4c61100 + 8e3406a commit 4c5a15a

File tree

3 files changed

+234
-0
lines changed

3 files changed

+234
-0
lines changed

configs/default_config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ llm:
3939
# API configuration
4040
api_base: "https://generativelanguage.googleapis.com/v1beta/openai/" # Base URL for API (change for non-OpenAI models)
4141
api_key: null # API key (defaults to OPENAI_API_KEY env variable)
42+
# or use ${VAR} syntax to specify which environment variable to read from:
43+
# api_key: ${GEMINI_API_KEY} # Reads API key from $GEMINI_API_KEY
4244

4345
# Generation parameters
4446
temperature: 0.7 # Temperature for generation (higher = more creative)

openevolve/config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import os
6+
import re
67
from dataclasses import asdict, dataclass, field
78
from pathlib import Path
89
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
@@ -13,6 +14,38 @@
1314
from openevolve.llm.base import LLMInterface
1415

1516

17+
_ENV_VAR_PATTERN = re.compile(r"^\$\{([^}]+)\}$") # ${VAR}
18+
19+
20+
def _resolve_env_var(value: Optional[str]) -> Optional[str]:
21+
"""
22+
Resolve ${VAR} environment variable reference in a string value.
23+
In current implementation pattern must match the entire string (e.g., "${OPENAI_API_KEY}"),
24+
not embedded within other text.
25+
26+
Args:
27+
value: The string value that may contain ${VAR} syntax
28+
29+
Returns:
30+
The resolved value with environment variable expanded, or original value if no match
31+
32+
Raises:
33+
ValueError: If the environment variable is referenced but not set
34+
"""
35+
if value is None:
36+
return None
37+
38+
match = _ENV_VAR_PATTERN.match(value)
39+
if not match:
40+
return value
41+
42+
var_name = match.group(1)
43+
env_value = os.environ.get(var_name)
44+
if env_value is None:
45+
raise ValueError(f"Environment variable {var_name} is not set")
46+
return env_value
47+
48+
1649
@dataclass
1750
class LLMModelConfig:
1851
"""Configuration for a single LLM model"""
@@ -45,6 +78,10 @@ class LLMModelConfig:
4578
# Reasoning parameters
4679
reasoning_effort: Optional[str] = None
4780

81+
def __post_init__(self):
82+
"""Post-initialization to resolve ${VAR} env var references in api_key"""
83+
self.api_key = _resolve_env_var(self.api_key)
84+
4885

4986
@dataclass
5087
class LLMConfig(LLMModelConfig):
@@ -81,6 +118,8 @@ class LLMConfig(LLMModelConfig):
81118

82119
def __post_init__(self):
83120
"""Post-initialization to set up model configurations"""
121+
super().__post_init__() # Resolve ${VAR} in api_key at LLMConfig level
122+
84123
# Handle backward compatibility for primary_model(_weight) and secondary_model(_weight).
85124
if self.primary_model:
86125
# Create primary model

tests/test_api_key_from_env.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
Tests for api_key ${VAR} environment variable substitution in configuration.
3+
"""
4+
5+
import os
6+
import tempfile
7+
import unittest
8+
9+
from openevolve.config import Config, LLMModelConfig, _resolve_env_var
10+
11+
12+
class TestEnvVarSubstitution(unittest.TestCase):
13+
"""Tests for ${VAR} environment variable substitution in api_key fields"""
14+
15+
def setUp(self):
16+
"""Set up test environment variables"""
17+
self.test_env_var = "TEST_OPENEVOLVE_API_KEY"
18+
self.test_api_key = "test-secret-key-12345"
19+
os.environ[self.test_env_var] = self.test_api_key
20+
21+
def tearDown(self):
22+
"""Clean up test environment variables"""
23+
if self.test_env_var in os.environ:
24+
del os.environ[self.test_env_var]
25+
26+
def test_resolve_env_var_with_match(self):
27+
"""Test that _resolve_env_var resolves ${VAR} syntax"""
28+
result = _resolve_env_var(f"${{{self.test_env_var}}}")
29+
self.assertEqual(result, self.test_api_key)
30+
31+
def test_resolve_env_var_no_match(self):
32+
"""Test that strings without ${VAR} are returned unchanged"""
33+
result = _resolve_env_var("regular-api-key")
34+
self.assertEqual(result, "regular-api-key")
35+
36+
def test_resolve_env_var_none(self):
37+
"""Test that None is returned unchanged"""
38+
result = _resolve_env_var(None)
39+
self.assertIsNone(result)
40+
41+
def test_resolve_env_var_missing_var(self):
42+
"""Test that missing environment variable raises ValueError"""
43+
with self.assertRaises(ValueError) as context:
44+
_resolve_env_var("${NONEXISTENT_ENV_VAR_12345}")
45+
46+
self.assertIn("NONEXISTENT_ENV_VAR_12345", str(context.exception))
47+
self.assertIn("is not set", str(context.exception))
48+
49+
def test_api_key_env_var_in_model_config(self):
50+
"""Test that api_key ${VAR} works in LLMModelConfig"""
51+
model_config = LLMModelConfig(name="test-model", api_key=f"${{{self.test_env_var}}}")
52+
53+
self.assertEqual(model_config.api_key, self.test_api_key)
54+
55+
def test_api_key_direct_value(self):
56+
"""Test that direct api_key value still works"""
57+
direct_key = "direct-api-key-value"
58+
model_config = LLMModelConfig(name="test-model", api_key=direct_key)
59+
60+
self.assertEqual(model_config.api_key, direct_key)
61+
62+
def test_api_key_none(self):
63+
"""Test that api_key can be None"""
64+
model_config = LLMModelConfig(name="test-model", api_key=None)
65+
66+
self.assertIsNone(model_config.api_key)
67+
68+
def test_api_key_env_var_in_llm_config(self):
69+
"""Test that api_key ${VAR} works at LLM config level"""
70+
yaml_config = {
71+
"log_level": "INFO",
72+
"llm": {
73+
"api_base": "https://api.openai.com/v1",
74+
"api_key": f"${{{self.test_env_var}}}",
75+
"models": [{"name": "test-model", "weight": 1.0}],
76+
},
77+
}
78+
79+
config = Config.from_dict(yaml_config)
80+
81+
self.assertEqual(config.llm.api_key, self.test_api_key)
82+
# Models should inherit the resolved api_key
83+
self.assertEqual(config.llm.models[0].api_key, self.test_api_key)
84+
85+
def test_api_key_env_var_per_model(self):
86+
"""Test that api_key ${VAR} can be specified per model"""
87+
# Set up a second env var for testing
88+
second_env_var = "TEST_OPENEVOLVE_API_KEY_2"
89+
second_api_key = "second-secret-key-67890"
90+
os.environ[second_env_var] = second_api_key
91+
92+
try:
93+
yaml_config = {
94+
"log_level": "INFO",
95+
"llm": {
96+
"api_base": "https://api.openai.com/v1",
97+
"models": [
98+
{
99+
"name": "model-1",
100+
"weight": 1.0,
101+
"api_key": f"${{{self.test_env_var}}}",
102+
},
103+
{
104+
"name": "model-2",
105+
"weight": 0.5,
106+
"api_key": f"${{{second_env_var}}}",
107+
},
108+
],
109+
},
110+
}
111+
112+
config = Config.from_dict(yaml_config)
113+
114+
self.assertEqual(config.llm.models[0].api_key, self.test_api_key)
115+
self.assertEqual(config.llm.models[1].api_key, second_api_key)
116+
finally:
117+
if second_env_var in os.environ:
118+
del os.environ[second_env_var]
119+
120+
def test_api_key_env_var_in_evaluator_models(self):
121+
"""Test that api_key ${VAR} works in evaluator_models"""
122+
yaml_config = {
123+
"log_level": "INFO",
124+
"llm": {
125+
"api_base": "https://api.openai.com/v1",
126+
"models": [{"name": "evolution-model", "weight": 1.0, "api_key": "direct-key"}],
127+
"evaluator_models": [
128+
{
129+
"name": "evaluator-model",
130+
"weight": 1.0,
131+
"api_key": f"${{{self.test_env_var}}}",
132+
}
133+
],
134+
},
135+
}
136+
137+
config = Config.from_dict(yaml_config)
138+
139+
self.assertEqual(config.llm.evaluator_models[0].api_key, self.test_api_key)
140+
141+
def test_yaml_file_loading_with_env_var(self):
142+
"""Test loading api_key ${VAR} from actual YAML file"""
143+
yaml_content = f"""
144+
log_level: INFO
145+
llm:
146+
api_base: https://api.openai.com/v1
147+
api_key: ${{{self.test_env_var}}}
148+
models:
149+
- name: test-model
150+
weight: 1.0
151+
"""
152+
153+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
154+
f.write(yaml_content)
155+
f.flush()
156+
157+
try:
158+
config = Config.from_yaml(f.name)
159+
self.assertEqual(config.llm.api_key, self.test_api_key)
160+
finally:
161+
os.unlink(f.name)
162+
163+
def test_mixed_api_key_sources(self):
164+
"""Test mixing direct api_key and ${VAR} in same config"""
165+
yaml_config = {
166+
"log_level": "INFO",
167+
"llm": {
168+
"api_base": "https://api.openai.com/v1",
169+
"api_key": "llm-level-direct-key",
170+
"models": [
171+
{
172+
"name": "model-with-env",
173+
"weight": 1.0,
174+
"api_key": f"${{{self.test_env_var}}}",
175+
},
176+
{
177+
"name": "model-with-direct",
178+
"weight": 0.5,
179+
"api_key": "model-direct-key",
180+
},
181+
],
182+
},
183+
}
184+
185+
config = Config.from_dict(yaml_config)
186+
187+
self.assertEqual(config.llm.api_key, "llm-level-direct-key")
188+
self.assertEqual(config.llm.models[0].api_key, self.test_api_key)
189+
self.assertEqual(config.llm.models[1].api_key, "model-direct-key")
190+
191+
192+
if __name__ == "__main__":
193+
unittest.main()

0 commit comments

Comments
 (0)