diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 1f508d4f..7346e641 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -39,5 +39,10 @@ jobs: - name: Install dependencies run: poetry install --with dev + - name: Create config.json + run: | + mkdir -p config + echo '{"mock-key": "mock-value"}' > config/config.json + - name: Run tests run: poetry run poe test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7c0e4ad3..1862585b 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,9 @@ ENV/ env.bak/ venv.bak/ +# Configuration +config/config.json + # Spyder project settings .spyderproject .spyproject diff --git a/README.md b/README.md index 2203f0d4..7adbe9f1 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ Because the companion uses the FastAPI framework, read the following documentati ### Configuration -For local development, LLMs can be configured inside the `config/models.yml` file. +For local development, you can configure LLMs by modifying the `config/config.json` file. +To use a configuration file from a different location, set the `CONFIG_PATH` environment variable to the path of your desired JSON configuration file. ## Code Checks diff --git a/config/config.yml b/config/config.yml index 89257823..e69de29b 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,9 +0,0 @@ -models: - - name: gpt-4o - deployment_id: d92ed4e1d4bafffc - temperature: 0 - - name: gpt-4o-mini - deployment_id: d3da334def2bb9c8 - temperature: 0 - - name: text-embedding-3-large - deployment_id: d6a35fa3fd8ca8b2 \ No newline at end of file diff --git a/scripts/k8s/companion-deployment.yaml b/scripts/k8s/companion-deployment.yaml index 48645de0..0f75f7ae 100644 --- a/scripts/k8s/companion-deployment.yaml +++ b/scripts/k8s/companion-deployment.yaml @@ -97,21 +97,21 @@ spec: - name: REDIS_PORT value: "6379" - name: CONFIG_PATH - value: "/mnt/config/models-config.yml" + value: "/mnt/config/models-config.json" envFrom: - configMapRef: name: ai-backend-config volumeMounts: - name: models-config - mountPath: /mnt/config/models-config.yml - subPath: models-config.yml + mountPath: /mnt/config/models-config.json + subPath: models-config.json volumes: - name: models-config configMap: name: ai-backend-config items: - - key: models-config.yml - path: models-config.yml + - key: models-config.json + path: models-config.json --- apiVersion: v1 diff --git a/src/utils/config.py b/src/utils/config.py index 3c9ec23b..a078340e 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,7 +1,7 @@ +import json import os from pathlib import Path -import yaml from pydantic import BaseModel from utils.logging import get_logger @@ -48,20 +48,29 @@ def find_config_file(start_path: Path, target: str) -> Path: def get_config() -> Config: """ - Get the configuration of the application by automatically locating the config file. + Get the model configuration of the application by automatically locating the config file. Returns: - Config: The configuration of the application + Config: The model configuration of the application """ # Get the absolute path of the current file current_file_path = Path(__file__).resolve() - target_config_file = os.environ.get("CONFIG_PATH", "config/config.yml") + target_config_file = os.environ.get("CONFIG_PATH", "config/config.json") # Find the config file by searching upwards config_file = find_config_file(current_file_path.parent, target_config_file) logger.info(f"Loading models config from: {config_file}") - with config_file.open() as f: - data = yaml.safe_load(f) - config = Config(**data) - return config + try: + with config_file.open() as file: + data = json.load(file) + # Extract only the "models" part of the configuration + models_data = data.get("models", []) + config = Config(models=models_data) + return config + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format in config file {config_file}: {e}") + raise + except Exception as e: + logger.error(f"Error loading config from {config_file}: {e}") + raise diff --git a/src/utils/settings.py b/src/utils/settings.py index 2d8fbab8..4ac8e617 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -1,9 +1,11 @@ +import json import logging import os import sys +from pathlib import Path from decouple import Config, RepositoryEnv, config -from dotenv import find_dotenv, load_dotenv +from dotenv import find_dotenv def is_running_pytest() -> bool: @@ -13,10 +15,44 @@ def is_running_pytest() -> bool: return "pytest" in sys.modules -project_root = os.path.dirname(os.path.abspath(__file__)) +def is_running_kubernetes() -> bool: + """Check if the code is running in a Kubernetes environment. + This is needed to identify if the code is running in a Kubernetes environment. + """ + return "KUBERNETES_SERVICE_HOST" in os.environ + + +def load_env_from_json() -> None: + """Load the configuration from the config.json file.""" + default_config_path = Path(__file__).parent.parent.parent / "config" / "config.json" + + config_path = Path(os.getenv("CONFIG_PATH", default_config_path)) + + try: + # Load the configuration from the given path and set the environment variables. + with config_path.open() as file: + config_file = json.load(file) + + # Set environment variables for all keys except "models" + for key, value in config_file.items(): + if key != "models": # Skip models + os.environ[key] = str(value) + except json.JSONDecodeError as e: + logging.error(f"Invalid JSON format in config file {config_path}: {e}") + raise + except FileNotFoundError: + logging.error( + f"Config file not found at {config_path}. Place the config file at the default location:" + f"{default_config_path} or set the CONFIG_PATH environment variable." + ) + raise + except Exception as e: + logging.error(f"Error loading config from {config_path}: {e}") + raise + if is_running_pytest(): - # Use .test.env for tests + # For tests use .env.test if available env_path = find_dotenv(".env.test") if env_path and os.path.exists(env_path): repository = RepositoryEnv(env_path) @@ -24,12 +60,15 @@ def is_running_pytest() -> bool: os.environ[key] = str(value) config = Config(repository) else: - logging.warning("No .test.env file found. Using .env file.") - load_dotenv() + # Load the config.json if no .env.test file is found + logging.warning("No .test.env file found. Using config.json.") + load_env_from_json() + # deepeval specific environment variables DEEPEVAL_TESTCASE_VERBOSE = config("DEEPEVAL_TESTCASE_VERBOSE", default="False") else: - load_dotenv() + # For production load the env variables needed dynamically from the config.json. + load_env_from_json() LOG_LEVEL = config("LOG_LEVEL", default="INFO") diff --git a/tests/unit/utils/test_config.py b/tests/unit/utils/test_config.py index f2279734..aa500fda 100644 --- a/tests/unit/utils/test_config.py +++ b/tests/unit/utils/test_config.py @@ -7,17 +7,28 @@ @pytest.mark.parametrize( - "yaml_content, expected_config", + "json_content, expected_config", [ ( + # Given: multiple models, two variables + # Expected: Config object with two models, no variables """ - models: - - name: model1 - deployment_id: dep1 - temperature: 0.0 - - name: model2 - deployment_id: dep2 - temperature: 0.5 + { + "VARIABLE_NAME": "value", + "VARIABLE_NAME2": "value2", + "models": [ + { + "name": "model1", + "deployment_id": "dep1", + "temperature": 0.0 + }, + { + "name": "model2", + "deployment_id": "dep2", + "temperature": 0.5 + } + ] + } """, Config( models=[ @@ -27,11 +38,19 @@ ), ), ( + # Given: single model, one variable + # Expected: Config object with one model, no variable """ - models: - - name: single_model - deployment_id: single_dep - temperature: 1 + { + "VARIABLE_NAME": "value", + "models": [ + { + "name": "single_model", + "deployment_id": "single_dep", + "temperature": 1 + } + ] + } """, Config( models=[ @@ -42,14 +61,22 @@ ), ), ( + # Given: no models, one variable + # Expected: Config object with no models, no variables """ - models: [] + { + "VARIABLE_NAME": "value", + "models": [] + } """, Config(models=[]), ), ], ) -def test_get_config(yaml_content, expected_config): - with patch.object(Path, "open", mock_open(read_data=yaml_content)): +def test_get_config(json_content, expected_config): + # Mock `Path.is_file` to always return True for the config file + with patch.object(Path, "open", mock_open(read_data=json_content)), patch.object( + Path, "is_file", return_value=True + ): result = get_config() assert result == expected_config diff --git a/tests/unit/utils/test_settings.py b/tests/unit/utils/test_settings.py new file mode 100644 index 00000000..0662698a --- /dev/null +++ b/tests/unit/utils/test_settings.py @@ -0,0 +1,74 @@ +import os +from json import JSONDecodeError +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest + +from utils.settings import load_env_from_json + + +@pytest.mark.parametrize( + "json_content, expected_env_variables", + [ + ( + # Given: Malformed Json + # Expected: Exception + """ + { "VARIABLE_NAME": "value", + """, + None, + ), + ( + # Given: Valid JSON with two variables + # Expected: Environment variables are set correctly + """ + { + "VARIABLE_NAME": "value", + "VARIABLE_NAME2": "value2" + } + """, + {"VARIABLE_NAME": "value", "VARIABLE_NAME2": "value2"}, + ), + ( + # Given: Valid JSON with two variables and a model configuration + # Expected: Environment variables are set correctly + """ + { + "VARIABLE_NAME": "value", + "VARIABLE_NAME2": "value2", + "models": [ + { + "name": "single_model", + "deployment_id": "single_dep", + "temperature": 1 + } + ] + } + """, + {"VARIABLE_NAME": "value", "VARIABLE_NAME2": "value2"}, + ), + ], +) +def test_load_env_from_json(json_content, expected_env_variables): + with patch.dict(os.environ, {"CONFIG_PATH": "/mocked/config.json"}), patch( + "os.path.exists", return_value=True + ), patch.object(Path, "open", mock_open(read_data=json_content)), patch.object( + Path, "is_file", return_value=bool(json_content) + ): + + if expected_env_variables is None: + # Then: Expect an exception for malformed JSON + with pytest.raises(JSONDecodeError): + load_env_from_json() + else: + # When: loading the environment variables from the config.json file + load_env_from_json() + + # Then: the environment variables are set as expected + for key, value in expected_env_variables.items(): + assert os.getenv(key) == value + + # Clean up the environment variables + for key in expected_env_variables: + os.environ.pop(key)