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# Configuration keys
 10MCP_SECRETS_KEY = "mcp_secrets"
 11
 12
 13class ConfigProperty:
 14    def __init__(
 15        self,
 16        type_: type,
 17        default: Any = None,
 18        env_var: Optional[str] = None,
 19        default_lambda: Optional[Callable[[], Any]] = None,
 20        sensitive: bool = False,
 21        sensitive_keys: Optional[List[str]] = None,
 22    ):
 23        self.type = type_
 24        self.default = default
 25        self.env_var = env_var
 26        self.default_lambda = default_lambda
 27        self.sensitive = sensitive
 28        self.sensitive_keys = sensitive_keys
 29
 30
 31class Config:
 32    _shared_instance = None
 33
 34    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 35        self._properties: Dict[str, ConfigProperty] = properties or {
 36            "user_id": ConfigProperty(
 37                str,
 38                env_var="KILN_USER_ID",
 39                default_lambda=_get_user_id,
 40            ),
 41            "autosave_runs": ConfigProperty(
 42                bool,
 43                env_var="KILN_AUTOSAVE_RUNS",
 44                default=True,
 45            ),
 46            "open_ai_api_key": ConfigProperty(
 47                str,
 48                env_var="OPENAI_API_KEY",
 49                sensitive=True,
 50            ),
 51            "groq_api_key": ConfigProperty(
 52                str,
 53                env_var="GROQ_API_KEY",
 54                sensitive=True,
 55            ),
 56            "ollama_base_url": ConfigProperty(
 57                str,
 58                env_var="OLLAMA_BASE_URL",
 59            ),
 60            "docker_model_runner_base_url": ConfigProperty(
 61                str,
 62                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 63            ),
 64            "bedrock_access_key": ConfigProperty(
 65                str,
 66                env_var="AWS_ACCESS_KEY_ID",
 67                sensitive=True,
 68            ),
 69            "bedrock_secret_key": ConfigProperty(
 70                str,
 71                env_var="AWS_SECRET_ACCESS_KEY",
 72                sensitive=True,
 73            ),
 74            "open_router_api_key": ConfigProperty(
 75                str,
 76                env_var="OPENROUTER_API_KEY",
 77                sensitive=True,
 78            ),
 79            "fireworks_api_key": ConfigProperty(
 80                str,
 81                env_var="FIREWORKS_API_KEY",
 82                sensitive=True,
 83            ),
 84            "fireworks_account_id": ConfigProperty(
 85                str,
 86                env_var="FIREWORKS_ACCOUNT_ID",
 87            ),
 88            "anthropic_api_key": ConfigProperty(
 89                str,
 90                env_var="ANTHROPIC_API_KEY",
 91                sensitive=True,
 92            ),
 93            "gemini_api_key": ConfigProperty(
 94                str,
 95                env_var="GEMINI_API_KEY",
 96                sensitive=True,
 97            ),
 98            "projects": ConfigProperty(
 99                list,
100                default_lambda=lambda: [],
101            ),
102            "azure_openai_api_key": ConfigProperty(
103                str,
104                env_var="AZURE_OPENAI_API_KEY",
105                sensitive=True,
106            ),
107            "azure_openai_endpoint": ConfigProperty(
108                str,
109                env_var="AZURE_OPENAI_ENDPOINT",
110            ),
111            "huggingface_api_key": ConfigProperty(
112                str,
113                env_var="HUGGINGFACE_API_KEY",
114                sensitive=True,
115            ),
116            "vertex_project_id": ConfigProperty(
117                str,
118                env_var="VERTEX_PROJECT_ID",
119            ),
120            "vertex_location": ConfigProperty(
121                str,
122                env_var="VERTEX_LOCATION",
123            ),
124            "together_api_key": ConfigProperty(
125                str,
126                env_var="TOGETHERAI_API_KEY",
127                sensitive=True,
128            ),
129            "wandb_api_key": ConfigProperty(
130                str,
131                env_var="WANDB_API_KEY",
132                sensitive=True,
133            ),
134            "siliconflow_cn_api_key": ConfigProperty(
135                str,
136                env_var="SILICONFLOW_CN_API_KEY",
137                sensitive=True,
138            ),
139            "wandb_base_url": ConfigProperty(
140                str,
141                env_var="WANDB_BASE_URL",
142            ),
143            "custom_models": ConfigProperty(
144                list,
145                default_lambda=lambda: [],
146            ),
147            "openai_compatible_providers": ConfigProperty(
148                list,
149                default_lambda=lambda: [],
150                sensitive_keys=["api_key"],
151            ),
152            "cerebras_api_key": ConfigProperty(
153                str,
154                env_var="CEREBRAS_API_KEY",
155                sensitive=True,
156            ),
157            "enable_demo_tools": ConfigProperty(
158                bool,
159                env_var="ENABLE_DEMO_TOOLS",
160                default=False,
161            ),
162            # Allow the user to set the path to lookup MCP server commands, like npx.
163            "custom_mcp_path": ConfigProperty(
164                str,
165                env_var="CUSTOM_MCP_PATH",
166            ),
167            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
168            MCP_SECRETS_KEY: ConfigProperty(
169                dict[str, str],
170                sensitive=True,
171            ),
172        }
173        self._lock = threading.Lock()
174        self._settings = self.load_settings()
175
176    @classmethod
177    def shared(cls):
178        if cls._shared_instance is None:
179            cls._shared_instance = cls()
180        return cls._shared_instance
181
182    # Get a value, mockable for testing
183    def get_value(self, name: str) -> Any:
184        try:
185            return self.__getattr__(name)
186        except AttributeError:
187            return None
188
189    def __getattr__(self, name: str) -> Any:
190        if name == "_properties":
191            return super().__getattribute__("_properties")
192        if name not in self._properties:
193            return super().__getattribute__(name)
194
195        property_config = self._properties[name]
196
197        # Check if the value is in settings
198        if name in self._settings:
199            value = self._settings[name]
200            return value if value is None else property_config.type(value)
201
202        # Check environment variable
203        if property_config.env_var and property_config.env_var in os.environ:
204            value = os.environ[property_config.env_var]
205            return property_config.type(value)
206
207        # Use default value or default_lambda
208        if property_config.default_lambda:
209            value = property_config.default_lambda()
210        else:
211            value = property_config.default
212
213        return None if value is None else property_config.type(value)
214
215    def __setattr__(self, name, value):
216        if name in ("_properties", "_settings", "_lock"):
217            super().__setattr__(name, value)
218        elif name in self._properties:
219            self.update_settings({name: value})
220        else:
221            raise AttributeError(f"Config has no attribute '{name}'")
222
223    @classmethod
224    def settings_dir(cls, create=True):
225        settings_dir = os.path.join(Path.home(), ".kiln_ai")
226        if create and not os.path.exists(settings_dir):
227            os.makedirs(settings_dir)
228        return settings_dir
229
230    @classmethod
231    def settings_path(cls, create=True):
232        settings_dir = cls.settings_dir(create)
233        return os.path.join(settings_dir, "settings.yaml")
234
235    @classmethod
236    def load_settings(cls):
237        if not os.path.isfile(cls.settings_path(create=False)):
238            return {}
239        with open(cls.settings_path(), "r") as f:
240            settings = yaml.safe_load(f.read()) or {}
241        return settings
242
243    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
244        if not hide_sensitive:
245            return self._settings
246
247        settings = {
248            k: "[hidden]"
249            if k in self._properties and self._properties[k].sensitive
250            else v
251            for k, v in self._settings.items()
252        }
253        # 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
254        for key, value in settings.items():
255            if key in self._properties and self._properties[key].sensitive_keys:
256                sensitive_keys = self._properties[key].sensitive_keys or []
257                for sensitive_key in sensitive_keys:
258                    if isinstance(value, list):
259                        for item in value:
260                            if sensitive_key in item:
261                                item[sensitive_key] = "[hidden]"
262
263        return settings
264
265    def save_setting(self, name: str, value: Any):
266        self.update_settings({name: value})
267
268    def update_settings(self, new_settings: Dict[str, Any]):
269        # Lock to prevent race conditions in multi-threaded scenarios
270        with self._lock:
271            # Fresh load to avoid clobbering changes from other instances
272            current_settings = self.load_settings()
273            current_settings.update(new_settings)
274            # remove None values
275            current_settings = {
276                k: v for k, v in current_settings.items() if v is not None
277            }
278            with open(self.settings_path(), "w") as f:
279                yaml.dump(current_settings, f)
280            self._settings = current_settings
281
282
283def _get_user_id():
284    try:
285        return getpass.getuser() or "unknown_user"
286    except Exception:
287        return "unknown_user"
MCP_SECRETS_KEY = 'mcp_secrets'
class ConfigProperty:
14class ConfigProperty:
15    def __init__(
16        self,
17        type_: type,
18        default: Any = None,
19        env_var: Optional[str] = None,
20        default_lambda: Optional[Callable[[], Any]] = None,
21        sensitive: bool = False,
22        sensitive_keys: Optional[List[str]] = None,
23    ):
24        self.type = type_
25        self.default = default
26        self.env_var = env_var
27        self.default_lambda = default_lambda
28        self.sensitive = sensitive
29        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)
15    def __init__(
16        self,
17        type_: type,
18        default: Any = None,
19        env_var: Optional[str] = None,
20        default_lambda: Optional[Callable[[], Any]] = None,
21        sensitive: bool = False,
22        sensitive_keys: Optional[List[str]] = None,
23    ):
24        self.type = type_
25        self.default = default
26        self.env_var = env_var
27        self.default_lambda = default_lambda
28        self.sensitive = sensitive
29        self.sensitive_keys = sensitive_keys
type
default
env_var
default_lambda
sensitive
sensitive_keys
class Config:
 32class Config:
 33    _shared_instance = None
 34
 35    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 36        self._properties: Dict[str, ConfigProperty] = properties or {
 37            "user_id": ConfigProperty(
 38                str,
 39                env_var="KILN_USER_ID",
 40                default_lambda=_get_user_id,
 41            ),
 42            "autosave_runs": ConfigProperty(
 43                bool,
 44                env_var="KILN_AUTOSAVE_RUNS",
 45                default=True,
 46            ),
 47            "open_ai_api_key": ConfigProperty(
 48                str,
 49                env_var="OPENAI_API_KEY",
 50                sensitive=True,
 51            ),
 52            "groq_api_key": ConfigProperty(
 53                str,
 54                env_var="GROQ_API_KEY",
 55                sensitive=True,
 56            ),
 57            "ollama_base_url": ConfigProperty(
 58                str,
 59                env_var="OLLAMA_BASE_URL",
 60            ),
 61            "docker_model_runner_base_url": ConfigProperty(
 62                str,
 63                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 64            ),
 65            "bedrock_access_key": ConfigProperty(
 66                str,
 67                env_var="AWS_ACCESS_KEY_ID",
 68                sensitive=True,
 69            ),
 70            "bedrock_secret_key": ConfigProperty(
 71                str,
 72                env_var="AWS_SECRET_ACCESS_KEY",
 73                sensitive=True,
 74            ),
 75            "open_router_api_key": ConfigProperty(
 76                str,
 77                env_var="OPENROUTER_API_KEY",
 78                sensitive=True,
 79            ),
 80            "fireworks_api_key": ConfigProperty(
 81                str,
 82                env_var="FIREWORKS_API_KEY",
 83                sensitive=True,
 84            ),
 85            "fireworks_account_id": ConfigProperty(
 86                str,
 87                env_var="FIREWORKS_ACCOUNT_ID",
 88            ),
 89            "anthropic_api_key": ConfigProperty(
 90                str,
 91                env_var="ANTHROPIC_API_KEY",
 92                sensitive=True,
 93            ),
 94            "gemini_api_key": ConfigProperty(
 95                str,
 96                env_var="GEMINI_API_KEY",
 97                sensitive=True,
 98            ),
 99            "projects": ConfigProperty(
100                list,
101                default_lambda=lambda: [],
102            ),
103            "azure_openai_api_key": ConfigProperty(
104                str,
105                env_var="AZURE_OPENAI_API_KEY",
106                sensitive=True,
107            ),
108            "azure_openai_endpoint": ConfigProperty(
109                str,
110                env_var="AZURE_OPENAI_ENDPOINT",
111            ),
112            "huggingface_api_key": ConfigProperty(
113                str,
114                env_var="HUGGINGFACE_API_KEY",
115                sensitive=True,
116            ),
117            "vertex_project_id": ConfigProperty(
118                str,
119                env_var="VERTEX_PROJECT_ID",
120            ),
121            "vertex_location": ConfigProperty(
122                str,
123                env_var="VERTEX_LOCATION",
124            ),
125            "together_api_key": ConfigProperty(
126                str,
127                env_var="TOGETHERAI_API_KEY",
128                sensitive=True,
129            ),
130            "wandb_api_key": ConfigProperty(
131                str,
132                env_var="WANDB_API_KEY",
133                sensitive=True,
134            ),
135            "siliconflow_cn_api_key": ConfigProperty(
136                str,
137                env_var="SILICONFLOW_CN_API_KEY",
138                sensitive=True,
139            ),
140            "wandb_base_url": ConfigProperty(
141                str,
142                env_var="WANDB_BASE_URL",
143            ),
144            "custom_models": ConfigProperty(
145                list,
146                default_lambda=lambda: [],
147            ),
148            "openai_compatible_providers": ConfigProperty(
149                list,
150                default_lambda=lambda: [],
151                sensitive_keys=["api_key"],
152            ),
153            "cerebras_api_key": ConfigProperty(
154                str,
155                env_var="CEREBRAS_API_KEY",
156                sensitive=True,
157            ),
158            "enable_demo_tools": ConfigProperty(
159                bool,
160                env_var="ENABLE_DEMO_TOOLS",
161                default=False,
162            ),
163            # Allow the user to set the path to lookup MCP server commands, like npx.
164            "custom_mcp_path": ConfigProperty(
165                str,
166                env_var="CUSTOM_MCP_PATH",
167            ),
168            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
169            MCP_SECRETS_KEY: ConfigProperty(
170                dict[str, str],
171                sensitive=True,
172            ),
173        }
174        self._lock = threading.Lock()
175        self._settings = self.load_settings()
176
177    @classmethod
178    def shared(cls):
179        if cls._shared_instance is None:
180            cls._shared_instance = cls()
181        return cls._shared_instance
182
183    # Get a value, mockable for testing
184    def get_value(self, name: str) -> Any:
185        try:
186            return self.__getattr__(name)
187        except AttributeError:
188            return None
189
190    def __getattr__(self, name: str) -> Any:
191        if name == "_properties":
192            return super().__getattribute__("_properties")
193        if name not in self._properties:
194            return super().__getattribute__(name)
195
196        property_config = self._properties[name]
197
198        # Check if the value is in settings
199        if name in self._settings:
200            value = self._settings[name]
201            return value if value is None else property_config.type(value)
202
203        # Check environment variable
204        if property_config.env_var and property_config.env_var in os.environ:
205            value = os.environ[property_config.env_var]
206            return property_config.type(value)
207
208        # Use default value or default_lambda
209        if property_config.default_lambda:
210            value = property_config.default_lambda()
211        else:
212            value = property_config.default
213
214        return None if value is None else property_config.type(value)
215
216    def __setattr__(self, name, value):
217        if name in ("_properties", "_settings", "_lock"):
218            super().__setattr__(name, value)
219        elif name in self._properties:
220            self.update_settings({name: value})
221        else:
222            raise AttributeError(f"Config has no attribute '{name}'")
223
224    @classmethod
225    def settings_dir(cls, create=True):
226        settings_dir = os.path.join(Path.home(), ".kiln_ai")
227        if create and not os.path.exists(settings_dir):
228            os.makedirs(settings_dir)
229        return settings_dir
230
231    @classmethod
232    def settings_path(cls, create=True):
233        settings_dir = cls.settings_dir(create)
234        return os.path.join(settings_dir, "settings.yaml")
235
236    @classmethod
237    def load_settings(cls):
238        if not os.path.isfile(cls.settings_path(create=False)):
239            return {}
240        with open(cls.settings_path(), "r") as f:
241            settings = yaml.safe_load(f.read()) or {}
242        return settings
243
244    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
245        if not hide_sensitive:
246            return self._settings
247
248        settings = {
249            k: "[hidden]"
250            if k in self._properties and self._properties[k].sensitive
251            else v
252            for k, v in self._settings.items()
253        }
254        # 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
255        for key, value in settings.items():
256            if key in self._properties and self._properties[key].sensitive_keys:
257                sensitive_keys = self._properties[key].sensitive_keys or []
258                for sensitive_key in sensitive_keys:
259                    if isinstance(value, list):
260                        for item in value:
261                            if sensitive_key in item:
262                                item[sensitive_key] = "[hidden]"
263
264        return settings
265
266    def save_setting(self, name: str, value: Any):
267        self.update_settings({name: value})
268
269    def update_settings(self, new_settings: Dict[str, Any]):
270        # Lock to prevent race conditions in multi-threaded scenarios
271        with self._lock:
272            # Fresh load to avoid clobbering changes from other instances
273            current_settings = self.load_settings()
274            current_settings.update(new_settings)
275            # remove None values
276            current_settings = {
277                k: v for k, v in current_settings.items() if v is not None
278            }
279            with open(self.settings_path(), "w") as f:
280                yaml.dump(current_settings, f)
281            self._settings = current_settings
Config( properties: Optional[Dict[str, ConfigProperty]] = None)
 35    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 36        self._properties: Dict[str, ConfigProperty] = properties or {
 37            "user_id": ConfigProperty(
 38                str,
 39                env_var="KILN_USER_ID",
 40                default_lambda=_get_user_id,
 41            ),
 42            "autosave_runs": ConfigProperty(
 43                bool,
 44                env_var="KILN_AUTOSAVE_RUNS",
 45                default=True,
 46            ),
 47            "open_ai_api_key": ConfigProperty(
 48                str,
 49                env_var="OPENAI_API_KEY",
 50                sensitive=True,
 51            ),
 52            "groq_api_key": ConfigProperty(
 53                str,
 54                env_var="GROQ_API_KEY",
 55                sensitive=True,
 56            ),
 57            "ollama_base_url": ConfigProperty(
 58                str,
 59                env_var="OLLAMA_BASE_URL",
 60            ),
 61            "docker_model_runner_base_url": ConfigProperty(
 62                str,
 63                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 64            ),
 65            "bedrock_access_key": ConfigProperty(
 66                str,
 67                env_var="AWS_ACCESS_KEY_ID",
 68                sensitive=True,
 69            ),
 70            "bedrock_secret_key": ConfigProperty(
 71                str,
 72                env_var="AWS_SECRET_ACCESS_KEY",
 73                sensitive=True,
 74            ),
 75            "open_router_api_key": ConfigProperty(
 76                str,
 77                env_var="OPENROUTER_API_KEY",
 78                sensitive=True,
 79            ),
 80            "fireworks_api_key": ConfigProperty(
 81                str,
 82                env_var="FIREWORKS_API_KEY",
 83                sensitive=True,
 84            ),
 85            "fireworks_account_id": ConfigProperty(
 86                str,
 87                env_var="FIREWORKS_ACCOUNT_ID",
 88            ),
 89            "anthropic_api_key": ConfigProperty(
 90                str,
 91                env_var="ANTHROPIC_API_KEY",
 92                sensitive=True,
 93            ),
 94            "gemini_api_key": ConfigProperty(
 95                str,
 96                env_var="GEMINI_API_KEY",
 97                sensitive=True,
 98            ),
 99            "projects": ConfigProperty(
100                list,
101                default_lambda=lambda: [],
102            ),
103            "azure_openai_api_key": ConfigProperty(
104                str,
105                env_var="AZURE_OPENAI_API_KEY",
106                sensitive=True,
107            ),
108            "azure_openai_endpoint": ConfigProperty(
109                str,
110                env_var="AZURE_OPENAI_ENDPOINT",
111            ),
112            "huggingface_api_key": ConfigProperty(
113                str,
114                env_var="HUGGINGFACE_API_KEY",
115                sensitive=True,
116            ),
117            "vertex_project_id": ConfigProperty(
118                str,
119                env_var="VERTEX_PROJECT_ID",
120            ),
121            "vertex_location": ConfigProperty(
122                str,
123                env_var="VERTEX_LOCATION",
124            ),
125            "together_api_key": ConfigProperty(
126                str,
127                env_var="TOGETHERAI_API_KEY",
128                sensitive=True,
129            ),
130            "wandb_api_key": ConfigProperty(
131                str,
132                env_var="WANDB_API_KEY",
133                sensitive=True,
134            ),
135            "siliconflow_cn_api_key": ConfigProperty(
136                str,
137                env_var="SILICONFLOW_CN_API_KEY",
138                sensitive=True,
139            ),
140            "wandb_base_url": ConfigProperty(
141                str,
142                env_var="WANDB_BASE_URL",
143            ),
144            "custom_models": ConfigProperty(
145                list,
146                default_lambda=lambda: [],
147            ),
148            "openai_compatible_providers": ConfigProperty(
149                list,
150                default_lambda=lambda: [],
151                sensitive_keys=["api_key"],
152            ),
153            "cerebras_api_key": ConfigProperty(
154                str,
155                env_var="CEREBRAS_API_KEY",
156                sensitive=True,
157            ),
158            "enable_demo_tools": ConfigProperty(
159                bool,
160                env_var="ENABLE_DEMO_TOOLS",
161                default=False,
162            ),
163            # Allow the user to set the path to lookup MCP server commands, like npx.
164            "custom_mcp_path": ConfigProperty(
165                str,
166                env_var="CUSTOM_MCP_PATH",
167            ),
168            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
169            MCP_SECRETS_KEY: ConfigProperty(
170                dict[str, str],
171                sensitive=True,
172            ),
173        }
174        self._lock = threading.Lock()
175        self._settings = self.load_settings()
@classmethod
def shared(cls):
177    @classmethod
178    def shared(cls):
179        if cls._shared_instance is None:
180            cls._shared_instance = cls()
181        return cls._shared_instance
def get_value(self, name: str) -> Any:
184    def get_value(self, name: str) -> Any:
185        try:
186            return self.__getattr__(name)
187        except AttributeError:
188            return None
@classmethod
def settings_dir(cls, create=True):
224    @classmethod
225    def settings_dir(cls, create=True):
226        settings_dir = os.path.join(Path.home(), ".kiln_ai")
227        if create and not os.path.exists(settings_dir):
228            os.makedirs(settings_dir)
229        return settings_dir
@classmethod
def settings_path(cls, create=True):
231    @classmethod
232    def settings_path(cls, create=True):
233        settings_dir = cls.settings_dir(create)
234        return os.path.join(settings_dir, "settings.yaml")
@classmethod
def load_settings(cls):
236    @classmethod
237    def load_settings(cls):
238        if not os.path.isfile(cls.settings_path(create=False)):
239            return {}
240        with open(cls.settings_path(), "r") as f:
241            settings = yaml.safe_load(f.read()) or {}
242        return settings
def settings(self, hide_sensitive=False) -> Dict[str, Any]:
244    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
245        if not hide_sensitive:
246            return self._settings
247
248        settings = {
249            k: "[hidden]"
250            if k in self._properties and self._properties[k].sensitive
251            else v
252            for k, v in self._settings.items()
253        }
254        # 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
255        for key, value in settings.items():
256            if key in self._properties and self._properties[key].sensitive_keys:
257                sensitive_keys = self._properties[key].sensitive_keys or []
258                for sensitive_key in sensitive_keys:
259                    if isinstance(value, list):
260                        for item in value:
261                            if sensitive_key in item:
262                                item[sensitive_key] = "[hidden]"
263
264        return settings
def save_setting(self, name: str, value: Any):
266    def save_setting(self, name: str, value: Any):
267        self.update_settings({name: value})
def update_settings(self, new_settings: Dict[str, Any]):
269    def update_settings(self, new_settings: Dict[str, Any]):
270        # Lock to prevent race conditions in multi-threaded scenarios
271        with self._lock:
272            # Fresh load to avoid clobbering changes from other instances
273            current_settings = self.load_settings()
274            current_settings.update(new_settings)
275            # remove None values
276            current_settings = {
277                k: v for k, v in current_settings.items() if v is not None
278            }
279            with open(self.settings_path(), "w") as f:
280                yaml.dump(current_settings, f)
281            self._settings = current_settings