kiln_ai.utils.config

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