kiln_ai.utils.config

  1import getpass
  2import os
  3import threading
  4from pathlib import Path
  5from typing import Any, Callable, Dict, Optional
  6
  7import yaml
  8
  9
 10class ConfigProperty:
 11    def __init__(
 12        self,
 13        type_: type,
 14        default: Any = None,
 15        env_var: Optional[str] = None,
 16        default_lambda: Optional[Callable[[], Any]] = None,
 17        sensitive: bool = False,
 18    ):
 19        self.type = type_
 20        self.default = default
 21        self.env_var = env_var
 22        self.default_lambda = default_lambda
 23        self.sensitive = sensitive
 24
 25
 26class Config:
 27    _shared_instance = None
 28
 29    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 30        self._properties: Dict[str, ConfigProperty] = properties or {
 31            "user_id": ConfigProperty(
 32                str,
 33                env_var="KILN_USER_ID",
 34                default_lambda=_get_user_id,
 35            ),
 36            "autosave_runs": ConfigProperty(
 37                bool,
 38                env_var="KILN_AUTOSAVE_RUNS",
 39                default=True,
 40            ),
 41            "open_ai_api_key": ConfigProperty(
 42                str,
 43                env_var="OPENAI_API_KEY",
 44                sensitive=True,
 45            ),
 46            "groq_api_key": ConfigProperty(
 47                str,
 48                env_var="GROQ_API_KEY",
 49                sensitive=True,
 50            ),
 51            "ollama_base_url": ConfigProperty(
 52                str,
 53                env_var="OLLAMA_BASE_URL",
 54            ),
 55            "bedrock_access_key": ConfigProperty(
 56                str,
 57                env_var="AWS_ACCESS_KEY_ID",
 58                sensitive=True,
 59            ),
 60            "bedrock_secret_key": ConfigProperty(
 61                str,
 62                env_var="AWS_SECRET_ACCESS_KEY",
 63                sensitive=True,
 64            ),
 65            "open_router_api_key": ConfigProperty(
 66                str,
 67                env_var="OPENROUTER_API_KEY",
 68                sensitive=True,
 69            ),
 70            "fireworks_api_key": ConfigProperty(
 71                str,
 72                env_var="FIREWORKS_API_KEY",
 73                sensitive=True,
 74            ),
 75            "fireworks_account_id": ConfigProperty(
 76                str,
 77                env_var="FIREWORKS_ACCOUNT_ID",
 78            ),
 79            "projects": ConfigProperty(
 80                list,
 81                default_lambda=lambda: [],
 82            ),
 83            "custom_models": ConfigProperty(
 84                list,
 85                default_lambda=lambda: [],
 86            ),
 87        }
 88        self._settings = self.load_settings()
 89
 90    @classmethod
 91    def shared(cls):
 92        if cls._shared_instance is None:
 93            cls._shared_instance = cls()
 94        return cls._shared_instance
 95
 96    # Get a value, mockable for testing
 97    def get_value(self, name: str) -> Any:
 98        try:
 99            return self.__getattr__(name)
100        except AttributeError:
101            return None
102
103    def __getattr__(self, name: str) -> Any:
104        if name == "_properties":
105            return super().__getattribute__("_properties")
106        if name not in self._properties:
107            return super().__getattribute__(name)
108
109        property_config = self._properties[name]
110
111        # Check if the value is in settings
112        if name in self._settings:
113            value = self._settings[name]
114            return value if value is None else property_config.type(value)
115
116        # Check environment variable
117        if property_config.env_var and property_config.env_var in os.environ:
118            value = os.environ[property_config.env_var]
119            return property_config.type(value)
120
121        # Use default value or default_lambda
122        if property_config.default_lambda:
123            value = property_config.default_lambda()
124        else:
125            value = property_config.default
126
127        return None if value is None else property_config.type(value)
128
129    def __setattr__(self, name, value):
130        if name in ("_properties", "_settings"):
131            super().__setattr__(name, value)
132        elif name in self._properties:
133            self.update_settings({name: value})
134        else:
135            raise AttributeError(f"Config has no attribute '{name}'")
136
137    @classmethod
138    def settings_path(cls, create=True):
139        settings_dir = os.path.join(Path.home(), ".kiln_ai")
140        if create and not os.path.exists(settings_dir):
141            os.makedirs(settings_dir)
142        return os.path.join(settings_dir, "settings.yaml")
143
144    @classmethod
145    def load_settings(cls):
146        if not os.path.isfile(cls.settings_path(create=False)):
147            return {}
148        with open(cls.settings_path(), "r") as f:
149            settings = yaml.safe_load(f.read()) or {}
150        return settings
151
152    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
153        if hide_sensitive:
154            return {
155                k: "[hidden]"
156                if k in self._properties and self._properties[k].sensitive
157                else v
158                for k, v in self._settings.items()
159            }
160        return self._settings
161
162    def save_setting(self, name: str, value: Any):
163        self.update_settings({name: value})
164
165    def update_settings(self, new_settings: Dict[str, Any]):
166        # Lock to prevent race conditions in multi-threaded scenarios
167        with threading.Lock():
168            # Fresh load to avoid clobbering changes from other instances
169            current_settings = self.load_settings()
170            current_settings.update(new_settings)
171            # remove None values
172            current_settings = {
173                k: v for k, v in current_settings.items() if v is not None
174            }
175            with open(self.settings_path(), "w") as f:
176                yaml.dump(current_settings, f)
177            self._settings = current_settings
178
179
180def _get_user_id():
181    try:
182        return getpass.getuser() or "unknown_user"
183    except Exception:
184        return "unknown_user"
class ConfigProperty:
11class ConfigProperty:
12    def __init__(
13        self,
14        type_: type,
15        default: Any = None,
16        env_var: Optional[str] = None,
17        default_lambda: Optional[Callable[[], Any]] = None,
18        sensitive: bool = False,
19    ):
20        self.type = type_
21        self.default = default
22        self.env_var = env_var
23        self.default_lambda = default_lambda
24        self.sensitive = sensitive
ConfigProperty( type_: type, default: Any = None, env_var: Optional[str] = None, default_lambda: Optional[Callable[[], Any]] = None, sensitive: bool = False)
12    def __init__(
13        self,
14        type_: type,
15        default: Any = None,
16        env_var: Optional[str] = None,
17        default_lambda: Optional[Callable[[], Any]] = None,
18        sensitive: bool = False,
19    ):
20        self.type = type_
21        self.default = default
22        self.env_var = env_var
23        self.default_lambda = default_lambda
24        self.sensitive = sensitive
type
default
env_var
default_lambda
sensitive
class Config:
 27class Config:
 28    _shared_instance = None
 29
 30    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 31        self._properties: Dict[str, ConfigProperty] = properties or {
 32            "user_id": ConfigProperty(
 33                str,
 34                env_var="KILN_USER_ID",
 35                default_lambda=_get_user_id,
 36            ),
 37            "autosave_runs": ConfigProperty(
 38                bool,
 39                env_var="KILN_AUTOSAVE_RUNS",
 40                default=True,
 41            ),
 42            "open_ai_api_key": ConfigProperty(
 43                str,
 44                env_var="OPENAI_API_KEY",
 45                sensitive=True,
 46            ),
 47            "groq_api_key": ConfigProperty(
 48                str,
 49                env_var="GROQ_API_KEY",
 50                sensitive=True,
 51            ),
 52            "ollama_base_url": ConfigProperty(
 53                str,
 54                env_var="OLLAMA_BASE_URL",
 55            ),
 56            "bedrock_access_key": ConfigProperty(
 57                str,
 58                env_var="AWS_ACCESS_KEY_ID",
 59                sensitive=True,
 60            ),
 61            "bedrock_secret_key": ConfigProperty(
 62                str,
 63                env_var="AWS_SECRET_ACCESS_KEY",
 64                sensitive=True,
 65            ),
 66            "open_router_api_key": ConfigProperty(
 67                str,
 68                env_var="OPENROUTER_API_KEY",
 69                sensitive=True,
 70            ),
 71            "fireworks_api_key": ConfigProperty(
 72                str,
 73                env_var="FIREWORKS_API_KEY",
 74                sensitive=True,
 75            ),
 76            "fireworks_account_id": ConfigProperty(
 77                str,
 78                env_var="FIREWORKS_ACCOUNT_ID",
 79            ),
 80            "projects": ConfigProperty(
 81                list,
 82                default_lambda=lambda: [],
 83            ),
 84            "custom_models": ConfigProperty(
 85                list,
 86                default_lambda=lambda: [],
 87            ),
 88        }
 89        self._settings = self.load_settings()
 90
 91    @classmethod
 92    def shared(cls):
 93        if cls._shared_instance is None:
 94            cls._shared_instance = cls()
 95        return cls._shared_instance
 96
 97    # Get a value, mockable for testing
 98    def get_value(self, name: str) -> Any:
 99        try:
100            return self.__getattr__(name)
101        except AttributeError:
102            return None
103
104    def __getattr__(self, name: str) -> Any:
105        if name == "_properties":
106            return super().__getattribute__("_properties")
107        if name not in self._properties:
108            return super().__getattribute__(name)
109
110        property_config = self._properties[name]
111
112        # Check if the value is in settings
113        if name in self._settings:
114            value = self._settings[name]
115            return value if value is None else property_config.type(value)
116
117        # Check environment variable
118        if property_config.env_var and property_config.env_var in os.environ:
119            value = os.environ[property_config.env_var]
120            return property_config.type(value)
121
122        # Use default value or default_lambda
123        if property_config.default_lambda:
124            value = property_config.default_lambda()
125        else:
126            value = property_config.default
127
128        return None if value is None else property_config.type(value)
129
130    def __setattr__(self, name, value):
131        if name in ("_properties", "_settings"):
132            super().__setattr__(name, value)
133        elif name in self._properties:
134            self.update_settings({name: value})
135        else:
136            raise AttributeError(f"Config has no attribute '{name}'")
137
138    @classmethod
139    def settings_path(cls, create=True):
140        settings_dir = os.path.join(Path.home(), ".kiln_ai")
141        if create and not os.path.exists(settings_dir):
142            os.makedirs(settings_dir)
143        return os.path.join(settings_dir, "settings.yaml")
144
145    @classmethod
146    def load_settings(cls):
147        if not os.path.isfile(cls.settings_path(create=False)):
148            return {}
149        with open(cls.settings_path(), "r") as f:
150            settings = yaml.safe_load(f.read()) or {}
151        return settings
152
153    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
154        if hide_sensitive:
155            return {
156                k: "[hidden]"
157                if k in self._properties and self._properties[k].sensitive
158                else v
159                for k, v in self._settings.items()
160            }
161        return self._settings
162
163    def save_setting(self, name: str, value: Any):
164        self.update_settings({name: value})
165
166    def update_settings(self, new_settings: Dict[str, Any]):
167        # Lock to prevent race conditions in multi-threaded scenarios
168        with threading.Lock():
169            # Fresh load to avoid clobbering changes from other instances
170            current_settings = self.load_settings()
171            current_settings.update(new_settings)
172            # remove None values
173            current_settings = {
174                k: v for k, v in current_settings.items() if v is not None
175            }
176            with open(self.settings_path(), "w") as f:
177                yaml.dump(current_settings, f)
178            self._settings = current_settings
Config( properties: Optional[Dict[str, ConfigProperty]] = None)
30    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
31        self._properties: Dict[str, ConfigProperty] = properties or {
32            "user_id": ConfigProperty(
33                str,
34                env_var="KILN_USER_ID",
35                default_lambda=_get_user_id,
36            ),
37            "autosave_runs": ConfigProperty(
38                bool,
39                env_var="KILN_AUTOSAVE_RUNS",
40                default=True,
41            ),
42            "open_ai_api_key": ConfigProperty(
43                str,
44                env_var="OPENAI_API_KEY",
45                sensitive=True,
46            ),
47            "groq_api_key": ConfigProperty(
48                str,
49                env_var="GROQ_API_KEY",
50                sensitive=True,
51            ),
52            "ollama_base_url": ConfigProperty(
53                str,
54                env_var="OLLAMA_BASE_URL",
55            ),
56            "bedrock_access_key": ConfigProperty(
57                str,
58                env_var="AWS_ACCESS_KEY_ID",
59                sensitive=True,
60            ),
61            "bedrock_secret_key": ConfigProperty(
62                str,
63                env_var="AWS_SECRET_ACCESS_KEY",
64                sensitive=True,
65            ),
66            "open_router_api_key": ConfigProperty(
67                str,
68                env_var="OPENROUTER_API_KEY",
69                sensitive=True,
70            ),
71            "fireworks_api_key": ConfigProperty(
72                str,
73                env_var="FIREWORKS_API_KEY",
74                sensitive=True,
75            ),
76            "fireworks_account_id": ConfigProperty(
77                str,
78                env_var="FIREWORKS_ACCOUNT_ID",
79            ),
80            "projects": ConfigProperty(
81                list,
82                default_lambda=lambda: [],
83            ),
84            "custom_models": ConfigProperty(
85                list,
86                default_lambda=lambda: [],
87            ),
88        }
89        self._settings = self.load_settings()
@classmethod
def shared(cls):
91    @classmethod
92    def shared(cls):
93        if cls._shared_instance is None:
94            cls._shared_instance = cls()
95        return cls._shared_instance
def get_value(self, name: str) -> Any:
 98    def get_value(self, name: str) -> Any:
 99        try:
100            return self.__getattr__(name)
101        except AttributeError:
102            return None
@classmethod
def settings_path(cls, create=True):
138    @classmethod
139    def settings_path(cls, create=True):
140        settings_dir = os.path.join(Path.home(), ".kiln_ai")
141        if create and not os.path.exists(settings_dir):
142            os.makedirs(settings_dir)
143        return os.path.join(settings_dir, "settings.yaml")
@classmethod
def load_settings(cls):
145    @classmethod
146    def load_settings(cls):
147        if not os.path.isfile(cls.settings_path(create=False)):
148            return {}
149        with open(cls.settings_path(), "r") as f:
150            settings = yaml.safe_load(f.read()) or {}
151        return settings
def settings(self, hide_sensitive=False) -> Dict[str, Any]:
153    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
154        if hide_sensitive:
155            return {
156                k: "[hidden]"
157                if k in self._properties and self._properties[k].sensitive
158                else v
159                for k, v in self._settings.items()
160            }
161        return self._settings
def save_setting(self, name: str, value: Any):
163    def save_setting(self, name: str, value: Any):
164        self.update_settings({name: value})
def update_settings(self, new_settings: Dict[str, Any]):
166    def update_settings(self, new_settings: Dict[str, Any]):
167        # Lock to prevent race conditions in multi-threaded scenarios
168        with threading.Lock():
169            # Fresh load to avoid clobbering changes from other instances
170            current_settings = self.load_settings()
171            current_settings.update(new_settings)
172            # remove None values
173            current_settings = {
174                k: v for k, v in current_settings.items() if v is not None
175            }
176            with open(self.settings_path(), "w") as f:
177                yaml.dump(current_settings, f)
178            self._settings = current_settings