omnimancer-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration manager for Omnimancer.
|
|
3
|
+
|
|
4
|
+
This module handles loading, saving, and managing configuration
|
|
5
|
+
including provider settings, API keys, and user preferences.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from cryptography.fernet import Fernet
|
|
15
|
+
|
|
16
|
+
from ..utils.errors import ConfigurationError, MCPConfigurationError
|
|
17
|
+
from .config_migration import ConfigMigration, ConfigValidator
|
|
18
|
+
from .models import (
|
|
19
|
+
Config,
|
|
20
|
+
ConfigProfile,
|
|
21
|
+
MCPConfig,
|
|
22
|
+
MCPServerConfig,
|
|
23
|
+
ProviderConfig,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigManager:
|
|
28
|
+
"""
|
|
29
|
+
Manages Omnimancer configuration.
|
|
30
|
+
|
|
31
|
+
This class handles loading and saving configuration files,
|
|
32
|
+
managing API keys, and providing access to settings.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the configuration manager.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config_path: Path to configuration file (optional)
|
|
41
|
+
"""
|
|
42
|
+
if config_path:
|
|
43
|
+
self.config_path = Path(config_path)
|
|
44
|
+
else:
|
|
45
|
+
self.config_path = Path.home() / ".omnimancer" / "config.json"
|
|
46
|
+
|
|
47
|
+
self.config: Optional[Config] = None
|
|
48
|
+
self._key_file = self.config_path.parent / ".key"
|
|
49
|
+
self._ensure_config_dir()
|
|
50
|
+
self._cipher = self._get_cipher()
|
|
51
|
+
|
|
52
|
+
def _ensure_config_dir(self) -> None:
|
|
53
|
+
"""Ensure the configuration directory exists."""
|
|
54
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
def _get_cipher(self) -> Fernet:
|
|
57
|
+
"""
|
|
58
|
+
Get or create encryption cipher.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Fernet cipher instance
|
|
62
|
+
"""
|
|
63
|
+
if self._key_file.exists():
|
|
64
|
+
with open(self._key_file, "rb") as f:
|
|
65
|
+
key = f.read()
|
|
66
|
+
else:
|
|
67
|
+
# Generate new key
|
|
68
|
+
key = Fernet.generate_key()
|
|
69
|
+
with open(self._key_file, "wb") as f:
|
|
70
|
+
f.write(key)
|
|
71
|
+
# Set restrictive permissions on key file
|
|
72
|
+
os.chmod(self._key_file, 0o600)
|
|
73
|
+
|
|
74
|
+
return Fernet(key)
|
|
75
|
+
|
|
76
|
+
def _encrypt_api_key(self, api_key: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Encrypt an API key.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
api_key: Plain text API key
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Encrypted API key as base64 string
|
|
85
|
+
"""
|
|
86
|
+
encrypted = self._cipher.encrypt(api_key.encode())
|
|
87
|
+
return base64.b64encode(encrypted).decode()
|
|
88
|
+
|
|
89
|
+
def _decrypt_api_key(self, encrypted_key: str) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Decrypt an API key.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
encrypted_key: Encrypted API key as base64 string
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Plain text API key
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ConfigurationError: If decryption fails
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
encrypted_bytes = base64.b64decode(encrypted_key.encode())
|
|
104
|
+
decrypted = self._cipher.decrypt(encrypted_bytes)
|
|
105
|
+
return decrypted.decode()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
raise ConfigurationError(f"Failed to decrypt API key: {e}")
|
|
108
|
+
|
|
109
|
+
def load_config(self) -> Config:
|
|
110
|
+
"""
|
|
111
|
+
Load configuration from file.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Loaded Config object
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ConfigurationError: If config file is invalid or missing
|
|
118
|
+
"""
|
|
119
|
+
if not self.config_path.exists() or self.config_path.stat().st_size == 0:
|
|
120
|
+
# Create default config for missing or empty files
|
|
121
|
+
self.config = self._create_default_config()
|
|
122
|
+
self.save_config()
|
|
123
|
+
return self.config
|
|
124
|
+
|
|
125
|
+
# Check if migration is needed
|
|
126
|
+
migration = ConfigMigration(self.config_path)
|
|
127
|
+
if migration.needs_migration():
|
|
128
|
+
success, messages = migration.migrate_config()
|
|
129
|
+
if not success:
|
|
130
|
+
raise ConfigurationError(
|
|
131
|
+
f"Configuration migration failed: {'; '.join(messages)}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
with open(self.config_path, "r") as f:
|
|
136
|
+
content = f.read().strip()
|
|
137
|
+
if not content:
|
|
138
|
+
# Handle empty file
|
|
139
|
+
self.config = self._create_default_config()
|
|
140
|
+
self.save_config()
|
|
141
|
+
return self.config
|
|
142
|
+
|
|
143
|
+
config_data = json.loads(content)
|
|
144
|
+
|
|
145
|
+
self.config = Config(**config_data)
|
|
146
|
+
return self.config
|
|
147
|
+
|
|
148
|
+
except (json.JSONDecodeError, ValueError):
|
|
149
|
+
# If JSON is invalid, create default config
|
|
150
|
+
self.config = self._create_default_config()
|
|
151
|
+
self.save_config()
|
|
152
|
+
return self.config
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise ConfigurationError(
|
|
155
|
+
f"Failed to load configuration: {e}", details=str(e)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def save_config(self, config: Optional[Config] = None) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Save configuration to file.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
config: Configuration to save (optional, uses current config if not provided)
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ConfigurationError: If saving fails
|
|
167
|
+
"""
|
|
168
|
+
config_to_save = config or self.config
|
|
169
|
+
if not config_to_save:
|
|
170
|
+
raise ConfigurationError("No configuration to save")
|
|
171
|
+
|
|
172
|
+
# Update current config if a new one was provided
|
|
173
|
+
if config:
|
|
174
|
+
self.config = config
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with open(self.config_path, "w") as f:
|
|
178
|
+
# Use model_dump with mode='json' to properly serialize datetime objects
|
|
179
|
+
config_dict = config_to_save.model_dump(mode="json")
|
|
180
|
+
json.dump(config_dict, f, indent=2)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise ConfigurationError(
|
|
183
|
+
f"Failed to save configuration: {e}", details=str(e)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def get_config(self) -> Config:
|
|
187
|
+
"""
|
|
188
|
+
Get the current configuration.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Current Config object
|
|
192
|
+
"""
|
|
193
|
+
if not self.config:
|
|
194
|
+
self.config = self.load_config()
|
|
195
|
+
return self.config
|
|
196
|
+
|
|
197
|
+
def get_provider_config(self, provider_name: str) -> Optional[ProviderConfig]:
|
|
198
|
+
"""
|
|
199
|
+
Get configuration for a specific provider.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
provider_name: Name of the provider
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
ProviderConfig or None if not found
|
|
206
|
+
"""
|
|
207
|
+
config = self.get_config()
|
|
208
|
+
return config.providers.get(provider_name)
|
|
209
|
+
|
|
210
|
+
def set_provider_config(
|
|
211
|
+
self, provider_name: str, provider_config: ProviderConfig
|
|
212
|
+
) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Set configuration for a provider.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
provider_name: Name of the provider
|
|
218
|
+
provider_config: Provider configuration
|
|
219
|
+
"""
|
|
220
|
+
config = self.get_config()
|
|
221
|
+
config.providers[provider_name] = provider_config
|
|
222
|
+
self.save_config()
|
|
223
|
+
|
|
224
|
+
def set_default_provider(self, provider_name: str) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Set the default provider.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
provider_name: Name of the provider
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
ConfigurationError: If provider is not configured
|
|
233
|
+
"""
|
|
234
|
+
config = self.get_config()
|
|
235
|
+
if provider_name not in config.providers:
|
|
236
|
+
raise ConfigurationError(f"Provider '{provider_name}' is not configured")
|
|
237
|
+
|
|
238
|
+
config.default_provider = provider_name
|
|
239
|
+
self.save_config()
|
|
240
|
+
|
|
241
|
+
def get_api_key(self, provider_name: str) -> Optional[str]:
|
|
242
|
+
"""
|
|
243
|
+
Get API key for a provider.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
provider_name: Name of the provider
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Decrypted API key or None if not found
|
|
250
|
+
"""
|
|
251
|
+
provider_config = self.get_provider_config(provider_name)
|
|
252
|
+
if provider_config and provider_config.api_key:
|
|
253
|
+
try:
|
|
254
|
+
return self._decrypt_api_key(provider_config.api_key)
|
|
255
|
+
except ConfigurationError:
|
|
256
|
+
# If decryption fails, assume it's a plain text key (for backward compatibility)
|
|
257
|
+
return provider_config.api_key
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def set_api_key(self, provider_name: str, api_key: str) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Set API key for a provider.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
provider_name: Name of the provider
|
|
266
|
+
api_key: API key to set (will be encrypted before storage)
|
|
267
|
+
"""
|
|
268
|
+
config = self.get_config()
|
|
269
|
+
encrypted_key = self._encrypt_api_key(api_key)
|
|
270
|
+
|
|
271
|
+
if provider_name in config.providers:
|
|
272
|
+
config.providers[provider_name].api_key = encrypted_key
|
|
273
|
+
else:
|
|
274
|
+
# Create new provider config with minimal settings
|
|
275
|
+
config.providers[provider_name] = ProviderConfig(
|
|
276
|
+
api_key=encrypted_key,
|
|
277
|
+
model="default", # Will be updated when provider is initialized
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
self.save_config()
|
|
281
|
+
|
|
282
|
+
def add_custom_model(self, model_info: "EnhancedModelInfo") -> None:
|
|
283
|
+
"""
|
|
284
|
+
Add a custom model to the configuration.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
model_info: Enhanced model information
|
|
288
|
+
"""
|
|
289
|
+
config = self.get_config()
|
|
290
|
+
|
|
291
|
+
# Check if model already exists (by name and provider)
|
|
292
|
+
existing_model = None
|
|
293
|
+
for i, model in enumerate(config.custom_models):
|
|
294
|
+
if model.name == model_info.name and model.provider == model_info.provider:
|
|
295
|
+
existing_model = i
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
if existing_model is not None:
|
|
299
|
+
# Update existing model
|
|
300
|
+
config.custom_models[existing_model] = model_info
|
|
301
|
+
else:
|
|
302
|
+
# Add new model
|
|
303
|
+
config.custom_models.append(model_info)
|
|
304
|
+
|
|
305
|
+
self.save_config()
|
|
306
|
+
|
|
307
|
+
def remove_custom_model(self, model_name: str, provider: str) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Remove a custom model from the configuration.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
model_name: Name of the model to remove
|
|
313
|
+
provider: Provider of the model
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
True if model was removed, False if not found
|
|
317
|
+
"""
|
|
318
|
+
config = self.get_config()
|
|
319
|
+
|
|
320
|
+
for i, model in enumerate(config.custom_models):
|
|
321
|
+
if model.name == model_name and model.provider == provider:
|
|
322
|
+
config.custom_models.pop(i)
|
|
323
|
+
self.save_config()
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
def get_custom_models(self) -> List["EnhancedModelInfo"]:
|
|
329
|
+
"""
|
|
330
|
+
Get list of custom models from configuration.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
List of custom model information
|
|
334
|
+
"""
|
|
335
|
+
config = self.get_config()
|
|
336
|
+
return config.custom_models.copy()
|
|
337
|
+
|
|
338
|
+
def list_custom_models(self) -> List[Dict[str, Any]]:
|
|
339
|
+
"""
|
|
340
|
+
Get custom models as a list of dictionaries for display.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of model dictionaries
|
|
344
|
+
"""
|
|
345
|
+
custom_models = self.get_custom_models()
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
"name": model.name,
|
|
349
|
+
"provider": model.provider,
|
|
350
|
+
"description": model.description,
|
|
351
|
+
"max_tokens": model.max_tokens,
|
|
352
|
+
"cost_per_million_input": model.cost_per_million_input,
|
|
353
|
+
"cost_per_million_output": model.cost_per_million_output,
|
|
354
|
+
"swe_score": model.swe_score,
|
|
355
|
+
"supports_tools": model.supports_tools,
|
|
356
|
+
"supports_multimodal": model.supports_multimodal,
|
|
357
|
+
"latest_version": model.latest_version,
|
|
358
|
+
"is_free": model.is_free,
|
|
359
|
+
}
|
|
360
|
+
for model in custom_models
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
def _create_default_config(self) -> Config:
|
|
364
|
+
"""
|
|
365
|
+
Create a default configuration.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Default Config object
|
|
369
|
+
"""
|
|
370
|
+
return Config(
|
|
371
|
+
default_provider="claude",
|
|
372
|
+
providers={},
|
|
373
|
+
storage_path=str(Path.home() / ".omnimancer"),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def get_storage_path(self) -> Path:
|
|
377
|
+
"""
|
|
378
|
+
Get the storage path for Omnimancer data.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Path object for storage directory
|
|
382
|
+
"""
|
|
383
|
+
config = self.get_config()
|
|
384
|
+
storage_path = Path(config.storage_path).expanduser()
|
|
385
|
+
storage_path.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
return storage_path
|
|
387
|
+
|
|
388
|
+
def is_first_run(self) -> bool:
|
|
389
|
+
"""
|
|
390
|
+
Check if this is the first run (no config file exists).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
True if this is the first run
|
|
394
|
+
"""
|
|
395
|
+
return not self.config_path.exists()
|
|
396
|
+
|
|
397
|
+
def validate_config(self) -> List[str]:
|
|
398
|
+
"""
|
|
399
|
+
Validate the current configuration.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
List of validation errors (empty if valid)
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
config = self.get_config()
|
|
406
|
+
except ConfigurationError as e:
|
|
407
|
+
return [f"Configuration file error: {e}"]
|
|
408
|
+
|
|
409
|
+
# Use optimized config validator with caching
|
|
410
|
+
from .config_validator import ConfigValidator
|
|
411
|
+
|
|
412
|
+
validator = ConfigValidator()
|
|
413
|
+
|
|
414
|
+
return validator.validate_config(config)
|
|
415
|
+
|
|
416
|
+
def setup_initial_config(
|
|
417
|
+
self,
|
|
418
|
+
provider_configs: Dict[str, Dict[str, str]],
|
|
419
|
+
default_provider: str,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Set up initial configuration with provider details.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
provider_configs: Dict mapping provider names to their config (api_key, model)
|
|
426
|
+
default_provider: Name of the default provider
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ConfigurationError: If setup fails
|
|
430
|
+
"""
|
|
431
|
+
if not provider_configs:
|
|
432
|
+
raise ConfigurationError("At least one provider must be configured")
|
|
433
|
+
|
|
434
|
+
if default_provider not in provider_configs:
|
|
435
|
+
raise ConfigurationError(
|
|
436
|
+
f"Default provider '{default_provider}' not in provider configs"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Create provider configurations
|
|
440
|
+
providers = {}
|
|
441
|
+
for provider_name, config_data in provider_configs.items():
|
|
442
|
+
if "api_key" not in config_data or "model" not in config_data:
|
|
443
|
+
raise ConfigurationError(
|
|
444
|
+
f"Provider '{provider_name}' missing api_key or model"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
providers[provider_name] = ProviderConfig(
|
|
448
|
+
api_key=self._encrypt_api_key(config_data["api_key"]),
|
|
449
|
+
model=config_data["model"],
|
|
450
|
+
max_tokens=config_data.get("max_tokens"),
|
|
451
|
+
temperature=config_data.get("temperature"),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Create and save configuration
|
|
455
|
+
self.config = Config(
|
|
456
|
+
default_provider=default_provider,
|
|
457
|
+
providers=providers,
|
|
458
|
+
storage_path=str(Path.home() / ".omnimancer"),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
self.save_config()
|
|
462
|
+
|
|
463
|
+
def update_provider_settings(self, provider_name: str, **kwargs) -> None:
|
|
464
|
+
"""
|
|
465
|
+
Update settings for a specific provider.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
provider_name: Name of the provider
|
|
469
|
+
**kwargs: Settings to update (model, max_tokens, temperature)
|
|
470
|
+
"""
|
|
471
|
+
config = self.get_config()
|
|
472
|
+
|
|
473
|
+
if provider_name not in config.providers:
|
|
474
|
+
raise ConfigurationError(f"Provider '{provider_name}' is not configured")
|
|
475
|
+
|
|
476
|
+
provider_config = config.providers[provider_name]
|
|
477
|
+
|
|
478
|
+
if "model" in kwargs:
|
|
479
|
+
provider_config.model = kwargs["model"]
|
|
480
|
+
if "max_tokens" in kwargs:
|
|
481
|
+
provider_config.max_tokens = kwargs["max_tokens"]
|
|
482
|
+
if "temperature" in kwargs:
|
|
483
|
+
provider_config.temperature = kwargs["temperature"]
|
|
484
|
+
|
|
485
|
+
self.save_config()
|
|
486
|
+
|
|
487
|
+
def get_mcp_config(self) -> MCPConfig:
|
|
488
|
+
"""
|
|
489
|
+
Get MCP configuration.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
MCPConfig object
|
|
493
|
+
"""
|
|
494
|
+
config = self.get_config()
|
|
495
|
+
return config.mcp
|
|
496
|
+
|
|
497
|
+
def set_mcp_config(self, mcp_config: MCPConfig) -> None:
|
|
498
|
+
"""
|
|
499
|
+
Set MCP configuration.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
mcp_config: MCP configuration to set
|
|
503
|
+
"""
|
|
504
|
+
config = self.get_config()
|
|
505
|
+
config.mcp = mcp_config
|
|
506
|
+
self.save_config()
|
|
507
|
+
|
|
508
|
+
def get_mcp_server_config(self, server_name: str) -> Optional[MCPServerConfig]:
|
|
509
|
+
"""
|
|
510
|
+
Get configuration for a specific MCP server.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
server_name: Name of the MCP server
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
MCPServerConfig or None if not found
|
|
517
|
+
"""
|
|
518
|
+
mcp_config = self.get_mcp_config()
|
|
519
|
+
return mcp_config.servers.get(server_name)
|
|
520
|
+
|
|
521
|
+
def set_mcp_server_config(
|
|
522
|
+
self, server_name: str, server_config: MCPServerConfig
|
|
523
|
+
) -> None:
|
|
524
|
+
"""
|
|
525
|
+
Set configuration for an MCP server.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
server_name: Name of the MCP server
|
|
529
|
+
server_config: Server configuration
|
|
530
|
+
"""
|
|
531
|
+
config = self.get_config()
|
|
532
|
+
config.mcp.servers[server_name] = server_config
|
|
533
|
+
self.save_config()
|
|
534
|
+
|
|
535
|
+
def remove_mcp_server_config(self, server_name: str) -> bool:
|
|
536
|
+
"""
|
|
537
|
+
Remove configuration for an MCP server.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
server_name: Name of the MCP server to remove
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
True if server was removed, False if not found
|
|
544
|
+
"""
|
|
545
|
+
config = self.get_config()
|
|
546
|
+
if server_name in config.mcp.servers:
|
|
547
|
+
del config.mcp.servers[server_name]
|
|
548
|
+
self.save_config()
|
|
549
|
+
return True
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
def get_enabled_mcp_servers(self) -> Dict[str, MCPServerConfig]:
|
|
553
|
+
"""
|
|
554
|
+
Get all enabled MCP server configurations.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
Dictionary of enabled MCP server configurations
|
|
558
|
+
"""
|
|
559
|
+
mcp_config = self.get_mcp_config()
|
|
560
|
+
return mcp_config.get_enabled_servers()
|
|
561
|
+
|
|
562
|
+
def enable_mcp_server(self, server_name: str) -> None:
|
|
563
|
+
"""
|
|
564
|
+
Enable an MCP server.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
server_name: Name of the MCP server
|
|
568
|
+
|
|
569
|
+
Raises:
|
|
570
|
+
MCPConfigurationError: If server is not configured
|
|
571
|
+
"""
|
|
572
|
+
server_config = self.get_mcp_server_config(server_name)
|
|
573
|
+
if not server_config:
|
|
574
|
+
raise MCPConfigurationError(f"MCP server '{server_name}' is not configured")
|
|
575
|
+
|
|
576
|
+
server_config.enabled = True
|
|
577
|
+
self.save_config()
|
|
578
|
+
|
|
579
|
+
def disable_mcp_server(self, server_name: str) -> None:
|
|
580
|
+
"""
|
|
581
|
+
Disable an MCP server.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
server_name: Name of the MCP server
|
|
585
|
+
|
|
586
|
+
Raises:
|
|
587
|
+
MCPConfigurationError: If server is not configured
|
|
588
|
+
"""
|
|
589
|
+
server_config = self.get_mcp_server_config(server_name)
|
|
590
|
+
if not server_config:
|
|
591
|
+
raise MCPConfigurationError(f"MCP server '{server_name}' is not configured")
|
|
592
|
+
|
|
593
|
+
server_config.enabled = False
|
|
594
|
+
self.save_config()
|
|
595
|
+
|
|
596
|
+
def validate_mcp_config(self) -> List[str]:
|
|
597
|
+
"""
|
|
598
|
+
Validate MCP configuration.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
List of validation errors (empty if valid)
|
|
602
|
+
"""
|
|
603
|
+
errors = []
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
mcp_config = self.get_mcp_config()
|
|
607
|
+
except Exception as e:
|
|
608
|
+
return [f"MCP configuration error: {e}"]
|
|
609
|
+
|
|
610
|
+
# Validate server configurations
|
|
611
|
+
for server_name, server_config in mcp_config.servers.items():
|
|
612
|
+
if not server_config.name:
|
|
613
|
+
errors.append(f"MCP server '{server_name}' has no name")
|
|
614
|
+
if not server_config.command:
|
|
615
|
+
errors.append(f"MCP server '{server_name}' has no command")
|
|
616
|
+
if server_config.timeout <= 0:
|
|
617
|
+
errors.append(
|
|
618
|
+
f"MCP server '{server_name}' has invalid timeout: {server_config.timeout}"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Validate global MCP settings
|
|
622
|
+
if mcp_config.auto_approve_timeout <= 0:
|
|
623
|
+
errors.append(
|
|
624
|
+
f"Invalid MCP auto_approve_timeout: {mcp_config.auto_approve_timeout}"
|
|
625
|
+
)
|
|
626
|
+
if mcp_config.max_concurrent_servers <= 0:
|
|
627
|
+
errors.append(
|
|
628
|
+
f"Invalid MCP max_concurrent_servers: {mcp_config.max_concurrent_servers}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
return errors
|
|
632
|
+
|
|
633
|
+
def load_mcp_config_from_file(self, mcp_config_path: str) -> None:
|
|
634
|
+
"""
|
|
635
|
+
Load MCP configuration from a separate file.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
mcp_config_path: Path to MCP configuration file
|
|
639
|
+
|
|
640
|
+
Raises:
|
|
641
|
+
MCPConfigurationError: If loading fails
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
config_path = Path(mcp_config_path).expanduser()
|
|
645
|
+
if not config_path.exists():
|
|
646
|
+
raise MCPConfigurationError(f"MCP config file not found: {config_path}")
|
|
647
|
+
|
|
648
|
+
with open(config_path, "r") as f:
|
|
649
|
+
mcp_data = json.load(f)
|
|
650
|
+
|
|
651
|
+
# Validate and create MCP config
|
|
652
|
+
mcp_config = MCPConfig(**mcp_data)
|
|
653
|
+
|
|
654
|
+
# Update main configuration
|
|
655
|
+
config = self.get_config()
|
|
656
|
+
config.mcp = mcp_config
|
|
657
|
+
self.save_config()
|
|
658
|
+
|
|
659
|
+
except json.JSONDecodeError as e:
|
|
660
|
+
raise MCPConfigurationError(f"Invalid JSON in MCP config file: {e}")
|
|
661
|
+
except Exception as e:
|
|
662
|
+
raise MCPConfigurationError(f"Failed to load MCP configuration: {e}")
|
|
663
|
+
|
|
664
|
+
def export_mcp_config_to_file(self, mcp_config_path: str) -> None:
|
|
665
|
+
"""
|
|
666
|
+
Export MCP configuration to a separate file.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
mcp_config_path: Path to export MCP configuration
|
|
670
|
+
|
|
671
|
+
Raises:
|
|
672
|
+
MCPConfigurationError: If export fails
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
config_path = Path(mcp_config_path).expanduser()
|
|
676
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
677
|
+
|
|
678
|
+
mcp_config = self.get_mcp_config()
|
|
679
|
+
|
|
680
|
+
with open(config_path, "w") as f:
|
|
681
|
+
json.dump(mcp_config.model_dump(mode="json"), f, indent=2)
|
|
682
|
+
|
|
683
|
+
except Exception as e:
|
|
684
|
+
raise MCPConfigurationError(f"Failed to export MCP configuration: {e}")
|
|
685
|
+
|
|
686
|
+
def validate_provider_config(
|
|
687
|
+
self, provider_name: str, provider_config: ProviderConfig
|
|
688
|
+
) -> List[str]:
|
|
689
|
+
"""
|
|
690
|
+
Validate configuration for a specific provider.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
provider_name: Name of the provider
|
|
694
|
+
provider_config: Provider configuration to validate
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
List of validation errors (empty if valid)
|
|
698
|
+
"""
|
|
699
|
+
errors = []
|
|
700
|
+
|
|
701
|
+
# Basic validation
|
|
702
|
+
if not provider_config.model:
|
|
703
|
+
errors.append(f"Provider '{provider_name}' has no model specified")
|
|
704
|
+
|
|
705
|
+
# Provider-specific validation
|
|
706
|
+
if provider_name == "claude":
|
|
707
|
+
errors.extend(self._validate_claude_config(provider_config))
|
|
708
|
+
elif provider_name == "openai":
|
|
709
|
+
errors.extend(self._validate_openai_config(provider_config))
|
|
710
|
+
elif provider_name == "gemini":
|
|
711
|
+
errors.extend(self._validate_gemini_config(provider_config))
|
|
712
|
+
elif provider_name == "cohere":
|
|
713
|
+
errors.extend(self._validate_cohere_config(provider_config))
|
|
714
|
+
elif provider_name == "ollama":
|
|
715
|
+
errors.extend(self._validate_ollama_config(provider_config))
|
|
716
|
+
else:
|
|
717
|
+
# Generic validation for unknown providers
|
|
718
|
+
if not provider_config.api_key:
|
|
719
|
+
errors.append(f"Provider '{provider_name}' has no API key")
|
|
720
|
+
|
|
721
|
+
return errors
|
|
722
|
+
|
|
723
|
+
def _validate_claude_config(self, config: ProviderConfig) -> List[str]:
|
|
724
|
+
"""Validate Claude provider configuration."""
|
|
725
|
+
errors = []
|
|
726
|
+
|
|
727
|
+
if not config.api_key:
|
|
728
|
+
errors.append("Claude provider requires an API key")
|
|
729
|
+
elif not config.api_key.startswith("sk-ant-"):
|
|
730
|
+
# Check if it's encrypted (base64) or plain text
|
|
731
|
+
try:
|
|
732
|
+
# Try to decrypt - if successful, it's encrypted
|
|
733
|
+
self._decrypt_api_key(config.api_key)
|
|
734
|
+
except:
|
|
735
|
+
# If decryption fails and doesn't start with sk-ant-, it's likely invalid
|
|
736
|
+
if not config.api_key.startswith("sk-ant-"):
|
|
737
|
+
errors.append("Claude API key should start with 'sk-ant-'")
|
|
738
|
+
|
|
739
|
+
# Validate model
|
|
740
|
+
valid_models = [
|
|
741
|
+
"claude-3-sonnet-20240229",
|
|
742
|
+
"claude-3-haiku-20240307",
|
|
743
|
+
"claude-3-opus-20240229",
|
|
744
|
+
"claude-3-5-sonnet-20241022",
|
|
745
|
+
]
|
|
746
|
+
if config.model not in valid_models:
|
|
747
|
+
errors.append(
|
|
748
|
+
f"Unknown Claude model '{config.model}'. Valid models: {', '.join(valid_models)}"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
return errors
|
|
752
|
+
|
|
753
|
+
def _validate_openai_config(self, config: ProviderConfig) -> List[str]:
|
|
754
|
+
"""Validate OpenAI provider configuration."""
|
|
755
|
+
errors = []
|
|
756
|
+
|
|
757
|
+
if not config.api_key:
|
|
758
|
+
errors.append("OpenAI provider requires an API key")
|
|
759
|
+
elif not config.api_key.startswith("sk-"):
|
|
760
|
+
# Check if it's encrypted
|
|
761
|
+
try:
|
|
762
|
+
decrypted = self._decrypt_api_key(config.api_key)
|
|
763
|
+
if not decrypted.startswith("sk-"):
|
|
764
|
+
errors.append("OpenAI API key should start with 'sk-'")
|
|
765
|
+
except:
|
|
766
|
+
errors.append("OpenAI API key should start with 'sk-'")
|
|
767
|
+
|
|
768
|
+
# Validate model
|
|
769
|
+
valid_models = [
|
|
770
|
+
"gpt-4",
|
|
771
|
+
"gpt-4-turbo",
|
|
772
|
+
"gpt-4o",
|
|
773
|
+
"gpt-4o-mini",
|
|
774
|
+
"gpt-3.5-turbo",
|
|
775
|
+
"gpt-3.5-turbo-16k",
|
|
776
|
+
]
|
|
777
|
+
if config.model not in valid_models:
|
|
778
|
+
errors.append(
|
|
779
|
+
f"Unknown OpenAI model '{config.model}'. Valid models: {', '.join(valid_models)}"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# Validate OpenAI-specific settings
|
|
783
|
+
if config.organization and not isinstance(config.organization, str):
|
|
784
|
+
errors.append("OpenAI organization must be a string")
|
|
785
|
+
|
|
786
|
+
return errors
|
|
787
|
+
|
|
788
|
+
def _validate_gemini_config(self, config: ProviderConfig) -> List[str]:
|
|
789
|
+
"""Validate Gemini provider configuration."""
|
|
790
|
+
errors = []
|
|
791
|
+
|
|
792
|
+
if not config.api_key:
|
|
793
|
+
errors.append("Gemini provider requires an API key")
|
|
794
|
+
elif not config.api_key.startswith("AIza"):
|
|
795
|
+
# Check if it's encrypted
|
|
796
|
+
try:
|
|
797
|
+
decrypted = self._decrypt_api_key(config.api_key)
|
|
798
|
+
if not decrypted.startswith("AIza"):
|
|
799
|
+
errors.append("Gemini API key should start with 'AIza'")
|
|
800
|
+
except:
|
|
801
|
+
errors.append("Gemini API key should start with 'AIza'")
|
|
802
|
+
|
|
803
|
+
# Validate model
|
|
804
|
+
valid_models = ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.0-pro"]
|
|
805
|
+
if config.model not in valid_models:
|
|
806
|
+
errors.append(
|
|
807
|
+
f"Unknown Gemini model '{config.model}'. Valid models: {', '.join(valid_models)}"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Validate Google-specific settings
|
|
811
|
+
if config.project_id and not isinstance(config.project_id, str):
|
|
812
|
+
errors.append("Google Cloud project_id must be a string")
|
|
813
|
+
|
|
814
|
+
return errors
|
|
815
|
+
|
|
816
|
+
def _validate_cohere_config(self, config: ProviderConfig) -> List[str]:
|
|
817
|
+
"""Validate Cohere provider configuration."""
|
|
818
|
+
errors = []
|
|
819
|
+
|
|
820
|
+
if not config.api_key:
|
|
821
|
+
errors.append("Cohere provider requires an API key")
|
|
822
|
+
# Cohere API keys don't have a standard prefix, so we can't validate format
|
|
823
|
+
|
|
824
|
+
# Validate model
|
|
825
|
+
valid_models = [
|
|
826
|
+
"command-r",
|
|
827
|
+
"command-r-plus",
|
|
828
|
+
"command-light",
|
|
829
|
+
"command",
|
|
830
|
+
]
|
|
831
|
+
if config.model not in valid_models:
|
|
832
|
+
errors.append(
|
|
833
|
+
f"Unknown Cohere model '{config.model}'. Valid models: {', '.join(valid_models)}"
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
return errors
|
|
837
|
+
|
|
838
|
+
def _validate_ollama_config(self, config: ProviderConfig) -> List[str]:
|
|
839
|
+
"""Validate Ollama provider configuration."""
|
|
840
|
+
errors = []
|
|
841
|
+
|
|
842
|
+
# Ollama doesn't require an API key (local server)
|
|
843
|
+
# But we should validate the base_url if provided
|
|
844
|
+
if config.base_url:
|
|
845
|
+
if not config.base_url.startswith(("http://", "https://")):
|
|
846
|
+
errors.append("Ollama base_url must start with 'http://' or 'https://'")
|
|
847
|
+
|
|
848
|
+
# Model validation is difficult for Ollama since models are dynamic
|
|
849
|
+
# We'll just ensure it's not empty
|
|
850
|
+
if not config.model:
|
|
851
|
+
errors.append("Ollama provider requires a model name")
|
|
852
|
+
|
|
853
|
+
# Validate timeout for Ollama (can be slower than cloud providers)
|
|
854
|
+
if config.timeout and config.timeout < 10:
|
|
855
|
+
errors.append(
|
|
856
|
+
"Ollama timeout should be at least 10 seconds for local inference"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
return errors
|
|
860
|
+
|
|
861
|
+
def get_provider_defaults(self, provider_name: str) -> Dict[str, any]:
|
|
862
|
+
"""
|
|
863
|
+
Get default configuration values for a provider.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
provider_name: Name of the provider
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Dictionary of default configuration values
|
|
870
|
+
"""
|
|
871
|
+
defaults = {
|
|
872
|
+
"claude": {
|
|
873
|
+
"model": "claude-3-sonnet-20240229",
|
|
874
|
+
"max_tokens": 4096,
|
|
875
|
+
"temperature": 0.7,
|
|
876
|
+
"provider_type": "claude",
|
|
877
|
+
"supports_tools": True,
|
|
878
|
+
"supports_multimodal": True,
|
|
879
|
+
"supports_streaming": True,
|
|
880
|
+
"supports_function_calling": True,
|
|
881
|
+
"supports_system_messages": True,
|
|
882
|
+
"enabled": True,
|
|
883
|
+
"priority": 10,
|
|
884
|
+
"max_retries": 3,
|
|
885
|
+
"retry_delay": 1.0,
|
|
886
|
+
"exponential_backoff": True,
|
|
887
|
+
"health_check_enabled": True,
|
|
888
|
+
"health_check_interval": 300,
|
|
889
|
+
"health_check_timeout": 10.0,
|
|
890
|
+
"auth_type": "api_key",
|
|
891
|
+
},
|
|
892
|
+
"openai": {
|
|
893
|
+
"model": "gpt-4",
|
|
894
|
+
"max_tokens": 4096,
|
|
895
|
+
"temperature": 0.7,
|
|
896
|
+
"provider_type": "openai",
|
|
897
|
+
"supports_tools": True,
|
|
898
|
+
"supports_multimodal": True,
|
|
899
|
+
"supports_streaming": True,
|
|
900
|
+
"supports_function_calling": True,
|
|
901
|
+
"supports_system_messages": True,
|
|
902
|
+
"enabled": True,
|
|
903
|
+
"priority": 9,
|
|
904
|
+
"max_retries": 3,
|
|
905
|
+
"retry_delay": 1.0,
|
|
906
|
+
"exponential_backoff": True,
|
|
907
|
+
"health_check_enabled": True,
|
|
908
|
+
"health_check_interval": 300,
|
|
909
|
+
"health_check_timeout": 10.0,
|
|
910
|
+
"auth_type": "api_key",
|
|
911
|
+
},
|
|
912
|
+
"gemini": {
|
|
913
|
+
"model": "gemini-1.5-pro",
|
|
914
|
+
"max_tokens": 8192,
|
|
915
|
+
"temperature": 0.7,
|
|
916
|
+
"provider_type": "gemini",
|
|
917
|
+
"supports_tools": True,
|
|
918
|
+
"supports_multimodal": True,
|
|
919
|
+
"supports_streaming": True,
|
|
920
|
+
"supports_function_calling": True,
|
|
921
|
+
"supports_system_messages": True,
|
|
922
|
+
"enabled": True,
|
|
923
|
+
"priority": 8,
|
|
924
|
+
"max_retries": 3,
|
|
925
|
+
"retry_delay": 1.0,
|
|
926
|
+
"exponential_backoff": True,
|
|
927
|
+
"health_check_enabled": True,
|
|
928
|
+
"health_check_interval": 300,
|
|
929
|
+
"health_check_timeout": 10.0,
|
|
930
|
+
"auth_type": "api_key",
|
|
931
|
+
"safety_settings": {
|
|
932
|
+
"HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE",
|
|
933
|
+
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
|
|
934
|
+
"HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_MEDIUM_AND_ABOVE",
|
|
935
|
+
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
"cohere": {
|
|
939
|
+
"model": "command-r",
|
|
940
|
+
"max_tokens": 4096,
|
|
941
|
+
"temperature": 0.7,
|
|
942
|
+
"provider_type": "cohere",
|
|
943
|
+
"supports_tools": False,
|
|
944
|
+
"supports_multimodal": False,
|
|
945
|
+
"supports_streaming": True,
|
|
946
|
+
"supports_function_calling": False,
|
|
947
|
+
"supports_system_messages": True,
|
|
948
|
+
"enabled": True,
|
|
949
|
+
"priority": 7,
|
|
950
|
+
"max_retries": 3,
|
|
951
|
+
"retry_delay": 1.0,
|
|
952
|
+
"exponential_backoff": True,
|
|
953
|
+
"health_check_enabled": True,
|
|
954
|
+
"health_check_interval": 300,
|
|
955
|
+
"health_check_timeout": 10.0,
|
|
956
|
+
"auth_type": "api_key",
|
|
957
|
+
},
|
|
958
|
+
"ollama": {
|
|
959
|
+
"model": "llama2",
|
|
960
|
+
"max_tokens": 4096,
|
|
961
|
+
"temperature": 0.7,
|
|
962
|
+
"base_url": "http://localhost:11434",
|
|
963
|
+
"timeout": 60.0,
|
|
964
|
+
"provider_type": "ollama",
|
|
965
|
+
"supports_tools": False,
|
|
966
|
+
"supports_multimodal": False,
|
|
967
|
+
"supports_streaming": True,
|
|
968
|
+
"supports_function_calling": False,
|
|
969
|
+
"supports_system_messages": True,
|
|
970
|
+
"enabled": True,
|
|
971
|
+
"priority": 5,
|
|
972
|
+
"max_retries": 3,
|
|
973
|
+
"retry_delay": 2.0,
|
|
974
|
+
"exponential_backoff": True,
|
|
975
|
+
"health_check_enabled": True,
|
|
976
|
+
"health_check_interval": 600,
|
|
977
|
+
"health_check_timeout": 15.0,
|
|
978
|
+
"auth_type": "none",
|
|
979
|
+
"num_ctx": 4096,
|
|
980
|
+
"repeat_penalty": 1.1,
|
|
981
|
+
},
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return defaults.get(
|
|
985
|
+
provider_name,
|
|
986
|
+
{
|
|
987
|
+
"max_tokens": 4096,
|
|
988
|
+
"temperature": 0.7,
|
|
989
|
+
"supports_tools": False,
|
|
990
|
+
"supports_multimodal": False,
|
|
991
|
+
"supports_streaming": True,
|
|
992
|
+
"supports_function_calling": False,
|
|
993
|
+
"supports_system_messages": True,
|
|
994
|
+
"enabled": True,
|
|
995
|
+
"priority": 0,
|
|
996
|
+
"max_retries": 3,
|
|
997
|
+
"retry_delay": 1.0,
|
|
998
|
+
"exponential_backoff": True,
|
|
999
|
+
"health_check_enabled": True,
|
|
1000
|
+
"health_check_interval": 300,
|
|
1001
|
+
"health_check_timeout": 10.0,
|
|
1002
|
+
"auth_type": "api_key",
|
|
1003
|
+
},
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
def create_provider_config(
|
|
1007
|
+
self, provider_name: str, api_key: str = None, **kwargs
|
|
1008
|
+
) -> ProviderConfig:
|
|
1009
|
+
"""
|
|
1010
|
+
Create a provider configuration with defaults.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
provider_name: Name of the provider
|
|
1014
|
+
api_key: API key (optional for providers like Ollama)
|
|
1015
|
+
**kwargs: Additional configuration options
|
|
1016
|
+
|
|
1017
|
+
Returns:
|
|
1018
|
+
ProviderConfig with defaults applied
|
|
1019
|
+
"""
|
|
1020
|
+
defaults = self.get_provider_defaults(provider_name)
|
|
1021
|
+
|
|
1022
|
+
# Merge defaults with provided kwargs
|
|
1023
|
+
config_data = {**defaults, **kwargs}
|
|
1024
|
+
|
|
1025
|
+
# Add API key if provided
|
|
1026
|
+
if api_key:
|
|
1027
|
+
config_data["api_key"] = self._encrypt_api_key(api_key)
|
|
1028
|
+
elif provider_name != "ollama": # Ollama doesn't need API key
|
|
1029
|
+
config_data["api_key"] = None
|
|
1030
|
+
|
|
1031
|
+
return ProviderConfig(**config_data)
|
|
1032
|
+
|
|
1033
|
+
def load_config_from_sources(
|
|
1034
|
+
self,
|
|
1035
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
1036
|
+
cli_args: Optional[Dict[str, Any]] = None,
|
|
1037
|
+
) -> Config:
|
|
1038
|
+
"""
|
|
1039
|
+
Load configuration from multiple sources with precedence.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
env_vars: Environment variables to consider
|
|
1043
|
+
cli_args: Command line arguments to consider
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
Merged configuration
|
|
1047
|
+
"""
|
|
1048
|
+
# Start with file-based configuration
|
|
1049
|
+
config = self.load_config()
|
|
1050
|
+
|
|
1051
|
+
# Merge environment variables
|
|
1052
|
+
if env_vars:
|
|
1053
|
+
config.merge_from_env(env_vars)
|
|
1054
|
+
|
|
1055
|
+
# Merge CLI arguments (highest precedence)
|
|
1056
|
+
if cli_args:
|
|
1057
|
+
self._merge_cli_args(config, cli_args)
|
|
1058
|
+
|
|
1059
|
+
return config
|
|
1060
|
+
|
|
1061
|
+
def _merge_cli_args(self, config: Config, cli_args: Dict[str, Any]) -> None:
|
|
1062
|
+
"""Merge CLI arguments into configuration."""
|
|
1063
|
+
# Map CLI arguments to configuration fields
|
|
1064
|
+
cli_mapping = {
|
|
1065
|
+
"provider": "default_provider",
|
|
1066
|
+
"model": lambda: self._set_provider_model(config, cli_args.get("model")),
|
|
1067
|
+
"temperature": lambda: self._set_provider_temperature(
|
|
1068
|
+
config, cli_args.get("temperature")
|
|
1069
|
+
),
|
|
1070
|
+
"max_tokens": lambda: self._set_provider_max_tokens(
|
|
1071
|
+
config, cli_args.get("max_tokens")
|
|
1072
|
+
),
|
|
1073
|
+
"debug": "debug_mode",
|
|
1074
|
+
"log_level": "log_level",
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for cli_arg, config_path in cli_mapping.items():
|
|
1078
|
+
if cli_arg in cli_args and cli_args[cli_arg] is not None:
|
|
1079
|
+
if callable(config_path):
|
|
1080
|
+
config_path()
|
|
1081
|
+
else:
|
|
1082
|
+
setattr(config, config_path, cli_args[cli_arg])
|
|
1083
|
+
config.config_sources[config_path] = f"cli:{cli_arg}"
|
|
1084
|
+
|
|
1085
|
+
def _set_provider_model(self, config: Config, model: str) -> None:
|
|
1086
|
+
"""Set model for the default provider."""
|
|
1087
|
+
if model and config.default_provider in config.providers:
|
|
1088
|
+
config.providers[config.default_provider].model = model
|
|
1089
|
+
config.config_sources[f"providers.{config.default_provider}.model"] = (
|
|
1090
|
+
"cli:model"
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
def _set_provider_temperature(self, config: Config, temperature: float) -> None:
|
|
1094
|
+
"""Set temperature for the default provider."""
|
|
1095
|
+
if temperature is not None and config.default_provider in config.providers:
|
|
1096
|
+
config.providers[config.default_provider].temperature = temperature
|
|
1097
|
+
config.config_sources[
|
|
1098
|
+
f"providers.{config.default_provider}.temperature"
|
|
1099
|
+
] = "cli:temperature"
|
|
1100
|
+
|
|
1101
|
+
def _set_provider_max_tokens(self, config: Config, max_tokens: int) -> None:
|
|
1102
|
+
"""Set max_tokens for the default provider."""
|
|
1103
|
+
if max_tokens is not None and config.default_provider in config.providers:
|
|
1104
|
+
config.providers[config.default_provider].max_tokens = max_tokens
|
|
1105
|
+
config.config_sources[f"providers.{config.default_provider}.max_tokens"] = (
|
|
1106
|
+
"cli:max_tokens"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def get_effective_config(self) -> Config:
|
|
1110
|
+
"""
|
|
1111
|
+
Get the effective configuration (considering active profile).
|
|
1112
|
+
|
|
1113
|
+
Returns:
|
|
1114
|
+
The active configuration
|
|
1115
|
+
"""
|
|
1116
|
+
config = self.get_config()
|
|
1117
|
+
return config.get_active_config()
|
|
1118
|
+
|
|
1119
|
+
def create_profile(
|
|
1120
|
+
self,
|
|
1121
|
+
name: str,
|
|
1122
|
+
description: Optional[str] = None,
|
|
1123
|
+
copy_from_current: bool = True,
|
|
1124
|
+
) -> ConfigProfile:
|
|
1125
|
+
"""
|
|
1126
|
+
Create a new configuration profile.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
name: Profile name
|
|
1130
|
+
description: Optional profile description
|
|
1131
|
+
copy_from_current: Whether to copy current settings
|
|
1132
|
+
|
|
1133
|
+
Returns:
|
|
1134
|
+
Created ConfigProfile
|
|
1135
|
+
"""
|
|
1136
|
+
config = self.get_config()
|
|
1137
|
+
|
|
1138
|
+
if copy_from_current:
|
|
1139
|
+
profile = config.create_profile(name, description)
|
|
1140
|
+
else:
|
|
1141
|
+
# Create empty profile with minimal settings
|
|
1142
|
+
profile = ConfigProfile(
|
|
1143
|
+
name=name,
|
|
1144
|
+
description=description,
|
|
1145
|
+
default_provider="claude",
|
|
1146
|
+
providers={},
|
|
1147
|
+
)
|
|
1148
|
+
config.profiles[name] = profile
|
|
1149
|
+
|
|
1150
|
+
self.save_config()
|
|
1151
|
+
return profile
|
|
1152
|
+
|
|
1153
|
+
def switch_profile(self, profile_name: str) -> None:
|
|
1154
|
+
"""
|
|
1155
|
+
Switch to a different configuration profile.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
profile_name: Name of the profile to switch to
|
|
1159
|
+
"""
|
|
1160
|
+
config = self.get_config()
|
|
1161
|
+
config.switch_profile(profile_name)
|
|
1162
|
+
self.save_config()
|
|
1163
|
+
|
|
1164
|
+
def delete_profile(self, profile_name: str) -> bool:
|
|
1165
|
+
"""
|
|
1166
|
+
Delete a configuration profile.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
profile_name: Name of the profile to delete
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
True if profile was deleted
|
|
1173
|
+
"""
|
|
1174
|
+
config = self.get_config()
|
|
1175
|
+
result = config.delete_profile(profile_name)
|
|
1176
|
+
if result:
|
|
1177
|
+
self.save_config()
|
|
1178
|
+
return result
|
|
1179
|
+
|
|
1180
|
+
def list_profiles(self) -> List[str]:
|
|
1181
|
+
"""
|
|
1182
|
+
List all available configuration profiles.
|
|
1183
|
+
|
|
1184
|
+
Returns:
|
|
1185
|
+
List of profile names
|
|
1186
|
+
"""
|
|
1187
|
+
config = self.get_config()
|
|
1188
|
+
return list(config.profiles.keys())
|
|
1189
|
+
|
|
1190
|
+
def get_profile(self, profile_name: str) -> Optional[ConfigProfile]:
|
|
1191
|
+
"""
|
|
1192
|
+
Get a specific configuration profile.
|
|
1193
|
+
|
|
1194
|
+
Args:
|
|
1195
|
+
profile_name: Name of the profile
|
|
1196
|
+
|
|
1197
|
+
Returns:
|
|
1198
|
+
ConfigProfile or None if not found
|
|
1199
|
+
"""
|
|
1200
|
+
config = self.get_config()
|
|
1201
|
+
return config.profiles.get(profile_name)
|
|
1202
|
+
|
|
1203
|
+
def backup_config(self, backup_path: Optional[str] = None) -> str:
|
|
1204
|
+
"""
|
|
1205
|
+
Create a backup of the current configuration.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
backup_path: Optional path for backup file
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
Path to the backup file
|
|
1212
|
+
"""
|
|
1213
|
+
if not backup_path:
|
|
1214
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1215
|
+
backup_path = str(
|
|
1216
|
+
self.config_path.parent / f"config_backup_{timestamp}.json"
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
backup_path = Path(backup_path)
|
|
1220
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1221
|
+
|
|
1222
|
+
# Copy current config to backup location
|
|
1223
|
+
if self.config_path.exists():
|
|
1224
|
+
import shutil
|
|
1225
|
+
|
|
1226
|
+
shutil.copy2(self.config_path, backup_path)
|
|
1227
|
+
|
|
1228
|
+
return str(backup_path)
|
|
1229
|
+
|
|
1230
|
+
def restore_config(self, backup_path: str) -> None:
|
|
1231
|
+
"""
|
|
1232
|
+
Restore configuration from a backup.
|
|
1233
|
+
|
|
1234
|
+
Args:
|
|
1235
|
+
backup_path: Path to the backup file
|
|
1236
|
+
|
|
1237
|
+
Raises:
|
|
1238
|
+
ConfigurationError: If restore fails
|
|
1239
|
+
"""
|
|
1240
|
+
backup_path = Path(backup_path)
|
|
1241
|
+
if not backup_path.exists():
|
|
1242
|
+
raise ConfigurationError(f"Backup file not found: {backup_path}")
|
|
1243
|
+
|
|
1244
|
+
try:
|
|
1245
|
+
# Validate backup file first
|
|
1246
|
+
with open(backup_path, "r") as f:
|
|
1247
|
+
backup_data = json.load(f)
|
|
1248
|
+
|
|
1249
|
+
# Try to create Config object to validate
|
|
1250
|
+
Config(**backup_data)
|
|
1251
|
+
|
|
1252
|
+
# If validation passes, restore the backup
|
|
1253
|
+
import shutil
|
|
1254
|
+
|
|
1255
|
+
shutil.copy2(backup_path, self.config_path)
|
|
1256
|
+
|
|
1257
|
+
# Reload configuration
|
|
1258
|
+
self.config = None
|
|
1259
|
+
self.load_config()
|
|
1260
|
+
|
|
1261
|
+
except Exception as e:
|
|
1262
|
+
raise ConfigurationError(f"Failed to restore configuration: {e}")
|
|
1263
|
+
|
|
1264
|
+
def get_config_info(self) -> Dict[str, Any]:
|
|
1265
|
+
"""
|
|
1266
|
+
Get information about the current configuration.
|
|
1267
|
+
|
|
1268
|
+
Returns:
|
|
1269
|
+
Dictionary with configuration metadata
|
|
1270
|
+
"""
|
|
1271
|
+
config = self.get_config()
|
|
1272
|
+
|
|
1273
|
+
return {
|
|
1274
|
+
"config_path": str(self.config_path),
|
|
1275
|
+
"config_version": config.config_version,
|
|
1276
|
+
"created_at": (
|
|
1277
|
+
config.created_at.isoformat() if config.created_at else None
|
|
1278
|
+
),
|
|
1279
|
+
"updated_at": (
|
|
1280
|
+
config.updated_at.isoformat() if config.updated_at else None
|
|
1281
|
+
),
|
|
1282
|
+
"providers_count": len(config.providers),
|
|
1283
|
+
"enabled_providers": len(config.get_enabled_providers()),
|
|
1284
|
+
"profiles_count": len(config.profiles),
|
|
1285
|
+
"active_profile": config.active_profile,
|
|
1286
|
+
"mcp_servers_count": len(config.mcp.servers),
|
|
1287
|
+
"enabled_mcp_servers": len(config.mcp.get_enabled_servers()),
|
|
1288
|
+
"default_provider": config.default_provider,
|
|
1289
|
+
"storage_path": config.storage_path,
|
|
1290
|
+
"config_sources": config.config_sources,
|
|
1291
|
+
}
|
|
1292
|
+
"""
|
|
1293
|
+
Load configuration from multiple sources (file, environment, CLI args).
|
|
1294
|
+
|
|
1295
|
+
Args:
|
|
1296
|
+
env_vars: Environment variables to merge
|
|
1297
|
+
cli_args: CLI arguments to merge
|
|
1298
|
+
|
|
1299
|
+
Returns:
|
|
1300
|
+
Loaded Config object with merged settings
|
|
1301
|
+
"""
|
|
1302
|
+
# Start with file-based configuration
|
|
1303
|
+
config = self.load_config()
|
|
1304
|
+
|
|
1305
|
+
# Merge environment variables
|
|
1306
|
+
if env_vars:
|
|
1307
|
+
config.merge_from_env(env_vars)
|
|
1308
|
+
|
|
1309
|
+
# Merge CLI arguments (highest priority)
|
|
1310
|
+
if cli_args:
|
|
1311
|
+
self._merge_cli_args(config, cli_args)
|
|
1312
|
+
|
|
1313
|
+
# Update timestamps
|
|
1314
|
+
from datetime import datetime
|
|
1315
|
+
|
|
1316
|
+
if not config.created_at:
|
|
1317
|
+
config.created_at = datetime.now()
|
|
1318
|
+
config.updated_at = datetime.now()
|
|
1319
|
+
|
|
1320
|
+
self.config = config
|
|
1321
|
+
return config
|
|
1322
|
+
|
|
1323
|
+
def _merge_cli_args(self, config: Config, cli_args: Dict[str, Any]) -> None:
|
|
1324
|
+
"""
|
|
1325
|
+
Merge CLI arguments into configuration.
|
|
1326
|
+
|
|
1327
|
+
Args:
|
|
1328
|
+
config: Configuration object to update
|
|
1329
|
+
cli_args: CLI arguments to merge
|
|
1330
|
+
"""
|
|
1331
|
+
# Map CLI arguments to configuration fields
|
|
1332
|
+
cli_mapping = {
|
|
1333
|
+
"provider": "default_provider",
|
|
1334
|
+
"model": "providers.{provider}.model",
|
|
1335
|
+
"max_tokens": "providers.{provider}.max_tokens",
|
|
1336
|
+
"temperature": "providers.{provider}.temperature",
|
|
1337
|
+
"storage_path": "storage_path",
|
|
1338
|
+
"profile": "active_profile",
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
for cli_arg, config_path in cli_mapping.items():
|
|
1342
|
+
if cli_arg in cli_args and cli_args[cli_arg] is not None:
|
|
1343
|
+
value = cli_args[cli_arg]
|
|
1344
|
+
|
|
1345
|
+
# Handle provider-specific settings
|
|
1346
|
+
if "{provider}" in config_path:
|
|
1347
|
+
provider = cli_args.get("provider", config.default_provider)
|
|
1348
|
+
config_path = config_path.format(provider=provider)
|
|
1349
|
+
|
|
1350
|
+
# Ensure provider exists
|
|
1351
|
+
if provider not in config.providers:
|
|
1352
|
+
config.providers[provider] = self.create_provider_config(
|
|
1353
|
+
provider
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
# Set the value and track source
|
|
1357
|
+
self._set_nested_value_in_config(config, config_path, value)
|
|
1358
|
+
config.config_sources[config_path] = "cli"
|
|
1359
|
+
|
|
1360
|
+
def _set_nested_value_in_config(
|
|
1361
|
+
self, config: Config, path: str, value: Any
|
|
1362
|
+
) -> None:
|
|
1363
|
+
"""
|
|
1364
|
+
Set a nested configuration value from a dot-separated path.
|
|
1365
|
+
|
|
1366
|
+
Args:
|
|
1367
|
+
config: Configuration object to update
|
|
1368
|
+
path: Dot-separated path to the setting
|
|
1369
|
+
value: Value to set
|
|
1370
|
+
"""
|
|
1371
|
+
parts = path.split(".")
|
|
1372
|
+
obj = config
|
|
1373
|
+
|
|
1374
|
+
# Navigate to the parent object
|
|
1375
|
+
for part in parts[:-1]:
|
|
1376
|
+
if hasattr(obj, part):
|
|
1377
|
+
obj = getattr(obj, part)
|
|
1378
|
+
elif isinstance(obj, dict) and part in obj:
|
|
1379
|
+
obj = obj[part]
|
|
1380
|
+
else:
|
|
1381
|
+
return # Path doesn't exist
|
|
1382
|
+
|
|
1383
|
+
# Set the final value with type conversion
|
|
1384
|
+
final_key = parts[-1]
|
|
1385
|
+
if hasattr(obj, final_key):
|
|
1386
|
+
current_value = getattr(obj, final_key)
|
|
1387
|
+
try:
|
|
1388
|
+
# Convert string values to appropriate types
|
|
1389
|
+
if isinstance(current_value, bool):
|
|
1390
|
+
converted_value = str(value).lower() in (
|
|
1391
|
+
"true",
|
|
1392
|
+
"1",
|
|
1393
|
+
"yes",
|
|
1394
|
+
"on",
|
|
1395
|
+
)
|
|
1396
|
+
elif isinstance(current_value, int):
|
|
1397
|
+
converted_value = int(value)
|
|
1398
|
+
elif isinstance(current_value, float):
|
|
1399
|
+
converted_value = float(value)
|
|
1400
|
+
else:
|
|
1401
|
+
converted_value = value
|
|
1402
|
+
setattr(obj, final_key, converted_value)
|
|
1403
|
+
except (ValueError, TypeError):
|
|
1404
|
+
# If conversion fails, keep as string
|
|
1405
|
+
setattr(obj, final_key, value)
|
|
1406
|
+
elif isinstance(obj, dict):
|
|
1407
|
+
obj[final_key] = value
|
|
1408
|
+
|
|
1409
|
+
def create_profile(
|
|
1410
|
+
self, name: str, description: Optional[str] = None
|
|
1411
|
+
) -> ConfigProfile:
|
|
1412
|
+
"""
|
|
1413
|
+
Create a new configuration profile.
|
|
1414
|
+
|
|
1415
|
+
Args:
|
|
1416
|
+
name: Profile name
|
|
1417
|
+
description: Optional profile description
|
|
1418
|
+
|
|
1419
|
+
Returns:
|
|
1420
|
+
Created ConfigProfile
|
|
1421
|
+
"""
|
|
1422
|
+
config = self.get_config()
|
|
1423
|
+
profile = config.create_profile(name, description)
|
|
1424
|
+
self.save_config()
|
|
1425
|
+
return profile
|
|
1426
|
+
|
|
1427
|
+
def list_profiles(self) -> Dict[str, str]:
|
|
1428
|
+
"""
|
|
1429
|
+
List all configuration profiles.
|
|
1430
|
+
|
|
1431
|
+
Returns:
|
|
1432
|
+
Dictionary mapping profile names to descriptions
|
|
1433
|
+
"""
|
|
1434
|
+
config = self.get_config()
|
|
1435
|
+
return {
|
|
1436
|
+
name: profile.description or "" for name, profile in config.profiles.items()
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
def switch_profile(self, profile_name: str) -> None:
|
|
1440
|
+
"""
|
|
1441
|
+
Switch to a different configuration profile.
|
|
1442
|
+
|
|
1443
|
+
Args:
|
|
1444
|
+
profile_name: Name of the profile to switch to
|
|
1445
|
+
|
|
1446
|
+
Raises:
|
|
1447
|
+
ConfigurationError: If profile doesn't exist
|
|
1448
|
+
"""
|
|
1449
|
+
config = self.get_config()
|
|
1450
|
+
if profile_name not in config.profiles:
|
|
1451
|
+
raise ConfigurationError(f"Profile '{profile_name}' not found")
|
|
1452
|
+
|
|
1453
|
+
config.switch_profile(profile_name)
|
|
1454
|
+
self.save_config()
|
|
1455
|
+
|
|
1456
|
+
def get_active_profile_name(self) -> Optional[str]:
|
|
1457
|
+
"""
|
|
1458
|
+
Get the name of the active profile.
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
Active profile name or None if no profile is active
|
|
1462
|
+
"""
|
|
1463
|
+
config = self.get_config()
|
|
1464
|
+
return config.active_profile
|
|
1465
|
+
|
|
1466
|
+
def delete_profile(self, profile_name: str) -> bool:
|
|
1467
|
+
"""
|
|
1468
|
+
Delete a configuration profile.
|
|
1469
|
+
|
|
1470
|
+
Args:
|
|
1471
|
+
profile_name: Name of the profile to delete
|
|
1472
|
+
|
|
1473
|
+
Returns:
|
|
1474
|
+
True if profile was deleted, False if not found
|
|
1475
|
+
"""
|
|
1476
|
+
config = self.get_config()
|
|
1477
|
+
result = config.delete_profile(profile_name)
|
|
1478
|
+
if result:
|
|
1479
|
+
self.save_config()
|
|
1480
|
+
return result
|
|
1481
|
+
|
|
1482
|
+
def update_profile_provider_settings(
|
|
1483
|
+
self, profile_name: str, provider_name: str, **kwargs
|
|
1484
|
+
) -> None:
|
|
1485
|
+
"""
|
|
1486
|
+
Update provider settings in a specific profile.
|
|
1487
|
+
|
|
1488
|
+
Args:
|
|
1489
|
+
profile_name: Name of the profile
|
|
1490
|
+
provider_name: Name of the provider
|
|
1491
|
+
**kwargs: Settings to update
|
|
1492
|
+
|
|
1493
|
+
Raises:
|
|
1494
|
+
ConfigurationError: If profile doesn't exist
|
|
1495
|
+
"""
|
|
1496
|
+
config = self.get_config()
|
|
1497
|
+
if profile_name not in config.profiles:
|
|
1498
|
+
raise ConfigurationError(f"Profile '{profile_name}' not found")
|
|
1499
|
+
|
|
1500
|
+
profile = config.profiles[profile_name]
|
|
1501
|
+
|
|
1502
|
+
if provider_name not in profile.providers:
|
|
1503
|
+
profile.providers[provider_name] = self.create_provider_config(
|
|
1504
|
+
provider_name
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
provider_config = profile.providers[provider_name]
|
|
1508
|
+
|
|
1509
|
+
# Update settings
|
|
1510
|
+
for key, value in kwargs.items():
|
|
1511
|
+
if hasattr(provider_config, key):
|
|
1512
|
+
setattr(provider_config, key, value)
|
|
1513
|
+
|
|
1514
|
+
self.save_config()
|
|
1515
|
+
|
|
1516
|
+
def get_effective_config(self) -> Config:
|
|
1517
|
+
"""
|
|
1518
|
+
Get the effective configuration (considering active profile).
|
|
1519
|
+
|
|
1520
|
+
Returns:
|
|
1521
|
+
Effective Config object
|
|
1522
|
+
"""
|
|
1523
|
+
config = self.get_config()
|
|
1524
|
+
return config.get_active_config()
|
|
1525
|
+
|
|
1526
|
+
def migrate_config_format(self) -> bool:
|
|
1527
|
+
"""
|
|
1528
|
+
Migrate configuration from older format to current format.
|
|
1529
|
+
|
|
1530
|
+
Returns:
|
|
1531
|
+
True if migration was performed, False if not needed
|
|
1532
|
+
"""
|
|
1533
|
+
if not self.config_path.exists():
|
|
1534
|
+
return False
|
|
1535
|
+
|
|
1536
|
+
try:
|
|
1537
|
+
with open(self.config_path, "r") as f:
|
|
1538
|
+
config_data = json.load(f)
|
|
1539
|
+
|
|
1540
|
+
# Check if migration is needed
|
|
1541
|
+
if config_data.get("config_version") == "2.0":
|
|
1542
|
+
return False # Already current version
|
|
1543
|
+
|
|
1544
|
+
# Backup original config
|
|
1545
|
+
self.backup_config()
|
|
1546
|
+
|
|
1547
|
+
# Migrate provider configurations
|
|
1548
|
+
if "providers" in config_data:
|
|
1549
|
+
for provider_name, provider_data in config_data["providers"].items():
|
|
1550
|
+
# Add provider_type if missing
|
|
1551
|
+
if "provider_type" not in provider_data:
|
|
1552
|
+
provider_data["provider_type"] = provider_name
|
|
1553
|
+
|
|
1554
|
+
# Add capability flags based on provider type
|
|
1555
|
+
defaults = self.get_provider_defaults(provider_name)
|
|
1556
|
+
for key in [
|
|
1557
|
+
"supports_tools",
|
|
1558
|
+
"supports_multimodal",
|
|
1559
|
+
"supports_streaming",
|
|
1560
|
+
]:
|
|
1561
|
+
if key not in provider_data:
|
|
1562
|
+
provider_data[key] = defaults.get(key, False)
|
|
1563
|
+
|
|
1564
|
+
# Add new fields
|
|
1565
|
+
config_data["config_version"] = "2.0"
|
|
1566
|
+
config_data["profiles"] = config_data.get("profiles", {})
|
|
1567
|
+
config_data["active_profile"] = config_data.get("active_profile")
|
|
1568
|
+
config_data["config_sources"] = config_data.get("config_sources", {})
|
|
1569
|
+
|
|
1570
|
+
from datetime import datetime
|
|
1571
|
+
|
|
1572
|
+
config_data["created_at"] = config_data.get(
|
|
1573
|
+
"created_at", datetime.now().isoformat()
|
|
1574
|
+
)
|
|
1575
|
+
config_data["updated_at"] = datetime.now().isoformat()
|
|
1576
|
+
|
|
1577
|
+
# Save migrated config
|
|
1578
|
+
with open(self.config_path, "w") as f:
|
|
1579
|
+
json.dump(config_data, f, indent=2)
|
|
1580
|
+
|
|
1581
|
+
# Reload config
|
|
1582
|
+
self.config = None
|
|
1583
|
+
self.load_config()
|
|
1584
|
+
|
|
1585
|
+
return True
|
|
1586
|
+
|
|
1587
|
+
except Exception as e:
|
|
1588
|
+
raise ConfigurationError(f"Failed to migrate configuration: {e}")
|
|
1589
|
+
|
|
1590
|
+
def backup_config(self) -> str:
|
|
1591
|
+
"""
|
|
1592
|
+
Create a backup of the current configuration.
|
|
1593
|
+
|
|
1594
|
+
Returns:
|
|
1595
|
+
Path to the backup file
|
|
1596
|
+
"""
|
|
1597
|
+
if not self.config_path.exists():
|
|
1598
|
+
raise ConfigurationError("No configuration file to backup")
|
|
1599
|
+
|
|
1600
|
+
from datetime import datetime
|
|
1601
|
+
|
|
1602
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1603
|
+
backup_path = self.config_path.parent / f"config_backup_{timestamp}.json"
|
|
1604
|
+
|
|
1605
|
+
import shutil
|
|
1606
|
+
|
|
1607
|
+
shutil.copy2(self.config_path, backup_path)
|
|
1608
|
+
|
|
1609
|
+
return str(backup_path)
|
|
1610
|
+
|
|
1611
|
+
def restore_config(self, backup_path: str) -> None:
|
|
1612
|
+
"""
|
|
1613
|
+
Restore configuration from a backup file.
|
|
1614
|
+
|
|
1615
|
+
Args:
|
|
1616
|
+
backup_path: Path to the backup file
|
|
1617
|
+
|
|
1618
|
+
Raises:
|
|
1619
|
+
ConfigurationError: If restore fails
|
|
1620
|
+
"""
|
|
1621
|
+
backup_file = Path(backup_path)
|
|
1622
|
+
if not backup_file.exists():
|
|
1623
|
+
raise ConfigurationError(f"Backup file not found: {backup_path}")
|
|
1624
|
+
|
|
1625
|
+
try:
|
|
1626
|
+
import shutil
|
|
1627
|
+
|
|
1628
|
+
shutil.copy2(backup_file, self.config_path)
|
|
1629
|
+
|
|
1630
|
+
# Clear cached config and reload
|
|
1631
|
+
self.config = None
|
|
1632
|
+
self.load_config()
|
|
1633
|
+
|
|
1634
|
+
except Exception as e:
|
|
1635
|
+
raise ConfigurationError(f"Failed to restore configuration: {e}")
|
|
1636
|
+
|
|
1637
|
+
def get_config_summary(self) -> Dict[str, Any]:
|
|
1638
|
+
"""
|
|
1639
|
+
Get a summary of the current configuration.
|
|
1640
|
+
|
|
1641
|
+
Returns:
|
|
1642
|
+
Dictionary with configuration summary
|
|
1643
|
+
"""
|
|
1644
|
+
config = self.get_config()
|
|
1645
|
+
|
|
1646
|
+
# Provider capabilities
|
|
1647
|
+
provider_capabilities = {}
|
|
1648
|
+
for provider_name, provider_config in config.providers.items():
|
|
1649
|
+
provider_capabilities[provider_name] = {
|
|
1650
|
+
"supports_tools": getattr(provider_config, "supports_tools", False),
|
|
1651
|
+
"supports_multimodal": getattr(
|
|
1652
|
+
provider_config, "supports_multimodal", False
|
|
1653
|
+
),
|
|
1654
|
+
"supports_streaming": getattr(
|
|
1655
|
+
provider_config, "supports_streaming", True
|
|
1656
|
+
),
|
|
1657
|
+
"provider_type": getattr(
|
|
1658
|
+
provider_config, "provider_type", provider_name
|
|
1659
|
+
),
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
return {
|
|
1663
|
+
"default_provider": config.default_provider,
|
|
1664
|
+
"providers": list(config.providers.keys()),
|
|
1665
|
+
"profiles": list(config.profiles.keys()),
|
|
1666
|
+
"active_profile": config.active_profile,
|
|
1667
|
+
"mcp_enabled": config.mcp.enabled,
|
|
1668
|
+
"mcp_servers": len(config.mcp.servers),
|
|
1669
|
+
"provider_capabilities": provider_capabilities,
|
|
1670
|
+
"config_version": config.config_version,
|
|
1671
|
+
"storage_path": config.storage_path,
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
def _set_nested_config_value(self, config: Config, path: str, value: Any) -> None:
|
|
1675
|
+
"""
|
|
1676
|
+
Set a nested configuration value from a dot-separated path.
|
|
1677
|
+
|
|
1678
|
+
Args:
|
|
1679
|
+
config: Configuration object to update
|
|
1680
|
+
path: Dot-separated path to the setting
|
|
1681
|
+
value: Value to set
|
|
1682
|
+
"""
|
|
1683
|
+
parts = path.split(".")
|
|
1684
|
+
obj = config
|
|
1685
|
+
|
|
1686
|
+
# Navigate to the parent object
|
|
1687
|
+
for part in parts[:-1]:
|
|
1688
|
+
if hasattr(obj, part):
|
|
1689
|
+
obj = getattr(obj, part)
|
|
1690
|
+
elif isinstance(obj, dict) and part in obj:
|
|
1691
|
+
obj = obj[part]
|
|
1692
|
+
else:
|
|
1693
|
+
return # Path doesn't exist
|
|
1694
|
+
|
|
1695
|
+
# Set the final value
|
|
1696
|
+
final_key = parts[-1]
|
|
1697
|
+
if hasattr(obj, final_key):
|
|
1698
|
+
setattr(obj, final_key, value)
|
|
1699
|
+
elif isinstance(obj, dict):
|
|
1700
|
+
obj[final_key] = value
|
|
1701
|
+
|
|
1702
|
+
def create_profile(
|
|
1703
|
+
self,
|
|
1704
|
+
name: str,
|
|
1705
|
+
description: Optional[str] = None,
|
|
1706
|
+
copy_from_current: bool = True,
|
|
1707
|
+
) -> None:
|
|
1708
|
+
"""
|
|
1709
|
+
Create a new configuration profile.
|
|
1710
|
+
|
|
1711
|
+
Args:
|
|
1712
|
+
name: Name of the profile
|
|
1713
|
+
description: Optional description
|
|
1714
|
+
copy_from_current: Whether to copy current settings to the new profile
|
|
1715
|
+
"""
|
|
1716
|
+
config = self.get_config()
|
|
1717
|
+
|
|
1718
|
+
if name in config.profiles:
|
|
1719
|
+
raise ConfigurationError(f"Profile '{name}' already exists")
|
|
1720
|
+
|
|
1721
|
+
if copy_from_current:
|
|
1722
|
+
profile = config.create_profile(name, description)
|
|
1723
|
+
else:
|
|
1724
|
+
# Create empty profile with defaults
|
|
1725
|
+
profile = ConfigProfile(
|
|
1726
|
+
name=name,
|
|
1727
|
+
description=description,
|
|
1728
|
+
default_provider="claude",
|
|
1729
|
+
providers={},
|
|
1730
|
+
)
|
|
1731
|
+
config.profiles[name] = profile
|
|
1732
|
+
|
|
1733
|
+
self.save_config()
|
|
1734
|
+
|
|
1735
|
+
def switch_profile(self, profile_name: str) -> None:
|
|
1736
|
+
"""
|
|
1737
|
+
Switch to a different configuration profile.
|
|
1738
|
+
|
|
1739
|
+
Args:
|
|
1740
|
+
profile_name: Name of the profile to switch to
|
|
1741
|
+
"""
|
|
1742
|
+
config = self.get_config()
|
|
1743
|
+
config.switch_profile(profile_name)
|
|
1744
|
+
self.save_config()
|
|
1745
|
+
|
|
1746
|
+
def delete_profile(self, profile_name: str) -> bool:
|
|
1747
|
+
"""
|
|
1748
|
+
Delete a configuration profile.
|
|
1749
|
+
|
|
1750
|
+
Args:
|
|
1751
|
+
profile_name: Name of the profile to delete
|
|
1752
|
+
|
|
1753
|
+
Returns:
|
|
1754
|
+
True if profile was deleted, False if not found
|
|
1755
|
+
"""
|
|
1756
|
+
config = self.get_config()
|
|
1757
|
+
result = config.delete_profile(profile_name)
|
|
1758
|
+
if result:
|
|
1759
|
+
self.save_config()
|
|
1760
|
+
return result
|
|
1761
|
+
|
|
1762
|
+
def list_profiles(self) -> Dict[str, str]:
|
|
1763
|
+
"""
|
|
1764
|
+
List all available configuration profiles.
|
|
1765
|
+
|
|
1766
|
+
Returns:
|
|
1767
|
+
Dictionary mapping profile names to descriptions
|
|
1768
|
+
"""
|
|
1769
|
+
config = self.get_config()
|
|
1770
|
+
return {
|
|
1771
|
+
name: profile.description or "No description"
|
|
1772
|
+
for name, profile in config.profiles.items()
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
def get_active_profile_name(self) -> Optional[str]:
|
|
1776
|
+
"""
|
|
1777
|
+
Get the name of the currently active profile.
|
|
1778
|
+
|
|
1779
|
+
Returns:
|
|
1780
|
+
Active profile name or None if using main config
|
|
1781
|
+
"""
|
|
1782
|
+
config = self.get_config()
|
|
1783
|
+
return config.active_profile
|
|
1784
|
+
|
|
1785
|
+
def get_effective_config(self) -> Config:
|
|
1786
|
+
"""
|
|
1787
|
+
Get the effective configuration (active profile or main config).
|
|
1788
|
+
|
|
1789
|
+
Returns:
|
|
1790
|
+
The configuration that should be used for operations
|
|
1791
|
+
"""
|
|
1792
|
+
config = self.get_config()
|
|
1793
|
+
return config.get_active_config()
|
|
1794
|
+
|
|
1795
|
+
def update_profile_provider_settings(
|
|
1796
|
+
self, profile_name: str, provider_name: str, **kwargs
|
|
1797
|
+
) -> None:
|
|
1798
|
+
"""
|
|
1799
|
+
Update provider settings for a specific profile.
|
|
1800
|
+
|
|
1801
|
+
Args:
|
|
1802
|
+
profile_name: Name of the profile
|
|
1803
|
+
provider_name: Name of the provider
|
|
1804
|
+
**kwargs: Settings to update
|
|
1805
|
+
"""
|
|
1806
|
+
config = self.get_config()
|
|
1807
|
+
|
|
1808
|
+
if profile_name not in config.profiles:
|
|
1809
|
+
raise ConfigurationError(f"Profile '{profile_name}' not found")
|
|
1810
|
+
|
|
1811
|
+
profile = config.profiles[profile_name]
|
|
1812
|
+
|
|
1813
|
+
if provider_name not in profile.providers:
|
|
1814
|
+
# Create provider config with defaults
|
|
1815
|
+
profile.providers[provider_name] = self.create_provider_config(
|
|
1816
|
+
provider_name
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
provider_config = profile.providers[provider_name]
|
|
1820
|
+
|
|
1821
|
+
# Update settings
|
|
1822
|
+
for key, value in kwargs.items():
|
|
1823
|
+
if hasattr(provider_config, key):
|
|
1824
|
+
setattr(provider_config, key, value)
|
|
1825
|
+
|
|
1826
|
+
self.save_config()
|
|
1827
|
+
|
|
1828
|
+
def migrate_config_format(self) -> bool:
|
|
1829
|
+
"""
|
|
1830
|
+
Migrate configuration from older format to current format.
|
|
1831
|
+
|
|
1832
|
+
Returns:
|
|
1833
|
+
True if migration was performed, False if no migration needed
|
|
1834
|
+
"""
|
|
1835
|
+
# Load the raw config data first
|
|
1836
|
+
if not self.config_path.exists():
|
|
1837
|
+
return False
|
|
1838
|
+
|
|
1839
|
+
try:
|
|
1840
|
+
with open(self.config_path, "r") as f:
|
|
1841
|
+
raw_config = json.load(f)
|
|
1842
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
1843
|
+
return False
|
|
1844
|
+
|
|
1845
|
+
# Check if migration is needed
|
|
1846
|
+
if raw_config.get("config_version") == "2.0":
|
|
1847
|
+
return False # Already current version
|
|
1848
|
+
|
|
1849
|
+
# Perform migration
|
|
1850
|
+
migrated = False
|
|
1851
|
+
|
|
1852
|
+
# Add missing fields with defaults
|
|
1853
|
+
if "profiles" not in raw_config:
|
|
1854
|
+
raw_config["profiles"] = {}
|
|
1855
|
+
migrated = True
|
|
1856
|
+
|
|
1857
|
+
if "active_profile" not in raw_config:
|
|
1858
|
+
raw_config["active_profile"] = None
|
|
1859
|
+
migrated = True
|
|
1860
|
+
|
|
1861
|
+
if "config_version" not in raw_config:
|
|
1862
|
+
raw_config["config_version"] = "2.0"
|
|
1863
|
+
migrated = True
|
|
1864
|
+
|
|
1865
|
+
if "config_sources" not in raw_config:
|
|
1866
|
+
raw_config["config_sources"] = {}
|
|
1867
|
+
migrated = True
|
|
1868
|
+
|
|
1869
|
+
# Migrate provider configurations
|
|
1870
|
+
if "providers" in raw_config:
|
|
1871
|
+
for provider_name, provider_config in raw_config["providers"].items():
|
|
1872
|
+
if "provider_type" not in provider_config:
|
|
1873
|
+
provider_config["provider_type"] = provider_name
|
|
1874
|
+
migrated = True
|
|
1875
|
+
|
|
1876
|
+
if "supports_tools" not in provider_config:
|
|
1877
|
+
defaults = self.get_provider_defaults(provider_name)
|
|
1878
|
+
provider_config["supports_tools"] = defaults.get(
|
|
1879
|
+
"supports_tools", False
|
|
1880
|
+
)
|
|
1881
|
+
provider_config["supports_multimodal"] = defaults.get(
|
|
1882
|
+
"supports_multimodal", False
|
|
1883
|
+
)
|
|
1884
|
+
provider_config["supports_streaming"] = defaults.get(
|
|
1885
|
+
"supports_streaming", True
|
|
1886
|
+
)
|
|
1887
|
+
migrated = True
|
|
1888
|
+
|
|
1889
|
+
# Add timestamps
|
|
1890
|
+
from datetime import datetime
|
|
1891
|
+
|
|
1892
|
+
if "created_at" not in raw_config or not raw_config["created_at"]:
|
|
1893
|
+
raw_config["created_at"] = datetime.now().isoformat()
|
|
1894
|
+
migrated = True
|
|
1895
|
+
|
|
1896
|
+
if "updated_at" not in raw_config:
|
|
1897
|
+
raw_config["updated_at"] = datetime.now().isoformat()
|
|
1898
|
+
migrated = True
|
|
1899
|
+
|
|
1900
|
+
if migrated:
|
|
1901
|
+
# Save the migrated config
|
|
1902
|
+
with open(self.config_path, "w") as f:
|
|
1903
|
+
json.dump(raw_config, f, indent=2)
|
|
1904
|
+
|
|
1905
|
+
# Reload the config
|
|
1906
|
+
self.config = None
|
|
1907
|
+
self.load_config()
|
|
1908
|
+
|
|
1909
|
+
return migrated
|
|
1910
|
+
|
|
1911
|
+
def backup_config(self, backup_path: Optional[str] = None) -> str:
|
|
1912
|
+
"""
|
|
1913
|
+
Create a backup of the current configuration.
|
|
1914
|
+
|
|
1915
|
+
Args:
|
|
1916
|
+
backup_path: Optional path for backup file
|
|
1917
|
+
|
|
1918
|
+
Returns:
|
|
1919
|
+
Path to the backup file
|
|
1920
|
+
"""
|
|
1921
|
+
if not backup_path:
|
|
1922
|
+
from datetime import datetime
|
|
1923
|
+
|
|
1924
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1925
|
+
backup_path = str(
|
|
1926
|
+
self.config_path.parent / f"config_backup_{timestamp}.json"
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1929
|
+
backup_path = Path(backup_path)
|
|
1930
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1931
|
+
|
|
1932
|
+
# Copy current config to backup location
|
|
1933
|
+
if self.config_path.exists():
|
|
1934
|
+
import shutil
|
|
1935
|
+
|
|
1936
|
+
shutil.copy2(self.config_path, backup_path)
|
|
1937
|
+
|
|
1938
|
+
return str(backup_path)
|
|
1939
|
+
|
|
1940
|
+
def restore_config(self, backup_path: str) -> None:
|
|
1941
|
+
"""
|
|
1942
|
+
Restore configuration from a backup file.
|
|
1943
|
+
|
|
1944
|
+
Args:
|
|
1945
|
+
backup_path: Path to the backup file
|
|
1946
|
+
|
|
1947
|
+
Raises:
|
|
1948
|
+
ConfigurationError: If restore fails
|
|
1949
|
+
"""
|
|
1950
|
+
backup_path = Path(backup_path)
|
|
1951
|
+
|
|
1952
|
+
if not backup_path.exists():
|
|
1953
|
+
raise ConfigurationError(f"Backup file not found: {backup_path}")
|
|
1954
|
+
|
|
1955
|
+
try:
|
|
1956
|
+
# Validate backup file
|
|
1957
|
+
with open(backup_path, "r") as f:
|
|
1958
|
+
backup_data = json.load(f)
|
|
1959
|
+
|
|
1960
|
+
# Test that we can create a Config object from backup data
|
|
1961
|
+
Config(**backup_data)
|
|
1962
|
+
|
|
1963
|
+
# Create backup of current config before restore
|
|
1964
|
+
if self.config_path.exists():
|
|
1965
|
+
import time
|
|
1966
|
+
from datetime import datetime
|
|
1967
|
+
|
|
1968
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1969
|
+
# Add milliseconds to avoid filename collision
|
|
1970
|
+
millis = int(time.time() * 1000) % 1000
|
|
1971
|
+
current_backup_path = (
|
|
1972
|
+
self.config_path.parent
|
|
1973
|
+
/ f"config_pre_restore_{timestamp}_{millis}.json"
|
|
1974
|
+
)
|
|
1975
|
+
import shutil
|
|
1976
|
+
|
|
1977
|
+
shutil.copy2(self.config_path, current_backup_path)
|
|
1978
|
+
|
|
1979
|
+
# Copy backup to current config location
|
|
1980
|
+
import shutil
|
|
1981
|
+
|
|
1982
|
+
shutil.copy2(backup_path, self.config_path)
|
|
1983
|
+
|
|
1984
|
+
# Clear cached config and reload
|
|
1985
|
+
self.config = None
|
|
1986
|
+
self.load_config()
|
|
1987
|
+
|
|
1988
|
+
except Exception as e:
|
|
1989
|
+
raise ConfigurationError(f"Failed to restore configuration: {e}")
|
|
1990
|
+
|
|
1991
|
+
def get_config_summary(self) -> Dict[str, Any]:
|
|
1992
|
+
"""
|
|
1993
|
+
Get a summary of the current configuration.
|
|
1994
|
+
|
|
1995
|
+
Returns:
|
|
1996
|
+
Dictionary with configuration summary
|
|
1997
|
+
"""
|
|
1998
|
+
config = self.get_effective_config()
|
|
1999
|
+
|
|
2000
|
+
summary = {
|
|
2001
|
+
"config_version": getattr(config, "config_version", "1.0"),
|
|
2002
|
+
"default_provider": config.default_provider,
|
|
2003
|
+
"providers": list(config.providers.keys()),
|
|
2004
|
+
"active_profile": getattr(config, "active_profile", None),
|
|
2005
|
+
"profiles": list(getattr(config, "profiles", {}).keys()),
|
|
2006
|
+
"mcp_enabled": config.mcp.enabled,
|
|
2007
|
+
"mcp_servers": len(config.mcp.servers),
|
|
2008
|
+
"enabled_mcp_servers": len(config.mcp.get_enabled_servers()),
|
|
2009
|
+
"storage_path": config.storage_path,
|
|
2010
|
+
"created_at": getattr(config, "created_at", None),
|
|
2011
|
+
"updated_at": getattr(config, "updated_at", None),
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
# Add provider capabilities summary
|
|
2015
|
+
provider_capabilities = {}
|
|
2016
|
+
for name, provider_config in config.providers.items():
|
|
2017
|
+
provider_capabilities[name] = {
|
|
2018
|
+
"model": provider_config.model,
|
|
2019
|
+
"supports_tools": getattr(provider_config, "supports_tools", False),
|
|
2020
|
+
"supports_multimodal": getattr(
|
|
2021
|
+
provider_config, "supports_multimodal", False
|
|
2022
|
+
),
|
|
2023
|
+
"supports_streaming": getattr(
|
|
2024
|
+
provider_config, "supports_streaming", True
|
|
2025
|
+
),
|
|
2026
|
+
"provider_type": getattr(provider_config, "provider_type", name),
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
summary["provider_capabilities"] = provider_capabilities
|
|
2030
|
+
|
|
2031
|
+
return summary
|
|
2032
|
+
|
|
2033
|
+
def validate_config_comprehensive(self) -> Dict[str, List[str]]:
|
|
2034
|
+
"""
|
|
2035
|
+
Perform comprehensive configuration validation using ConfigValidator.
|
|
2036
|
+
|
|
2037
|
+
Returns:
|
|
2038
|
+
Dictionary mapping validation categories to error lists
|
|
2039
|
+
"""
|
|
2040
|
+
try:
|
|
2041
|
+
config = self.get_config()
|
|
2042
|
+
validator = ConfigValidator(config)
|
|
2043
|
+
return validator.validate_full_config()
|
|
2044
|
+
except Exception as e:
|
|
2045
|
+
return {"general": [f"Configuration validation failed: {e}"]}
|
|
2046
|
+
|
|
2047
|
+
def create_config_backup(self, backup_path: Optional[str] = None) -> str:
|
|
2048
|
+
"""
|
|
2049
|
+
Create a backup of the current configuration.
|
|
2050
|
+
|
|
2051
|
+
Args:
|
|
2052
|
+
backup_path: Optional path for backup file
|
|
2053
|
+
|
|
2054
|
+
Returns:
|
|
2055
|
+
Path to the backup file
|
|
2056
|
+
|
|
2057
|
+
Raises:
|
|
2058
|
+
ConfigurationError: If backup creation fails
|
|
2059
|
+
"""
|
|
2060
|
+
migration = ConfigMigration(self.config_path)
|
|
2061
|
+
try:
|
|
2062
|
+
if backup_path:
|
|
2063
|
+
backup_path_obj = Path(backup_path)
|
|
2064
|
+
backup_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
2065
|
+
import shutil
|
|
2066
|
+
|
|
2067
|
+
shutil.copy2(self.config_path, backup_path_obj)
|
|
2068
|
+
return str(backup_path_obj)
|
|
2069
|
+
else:
|
|
2070
|
+
return str(migration.create_backup())
|
|
2071
|
+
except Exception as e:
|
|
2072
|
+
raise ConfigurationError(f"Failed to create configuration backup: {e}")
|
|
2073
|
+
|
|
2074
|
+
def restore_config_backup(self, backup_path: str) -> None:
|
|
2075
|
+
"""
|
|
2076
|
+
Restore configuration from a backup.
|
|
2077
|
+
|
|
2078
|
+
Args:
|
|
2079
|
+
backup_path: Path to the backup file
|
|
2080
|
+
|
|
2081
|
+
Raises:
|
|
2082
|
+
ConfigurationError: If restore fails
|
|
2083
|
+
"""
|
|
2084
|
+
migration = ConfigMigration(self.config_path)
|
|
2085
|
+
try:
|
|
2086
|
+
migration.restore_backup(Path(backup_path))
|
|
2087
|
+
# Reload configuration after restore
|
|
2088
|
+
self.config = None
|
|
2089
|
+
self.load_config()
|
|
2090
|
+
except Exception as e:
|
|
2091
|
+
raise ConfigurationError(f"Failed to restore configuration backup: {e}")
|
|
2092
|
+
|
|
2093
|
+
def list_config_backups(self) -> List[Dict[str, Any]]:
|
|
2094
|
+
"""
|
|
2095
|
+
List available configuration backups.
|
|
2096
|
+
|
|
2097
|
+
Returns:
|
|
2098
|
+
List of backup information dictionaries
|
|
2099
|
+
"""
|
|
2100
|
+
migration = ConfigMigration(self.config_path)
|
|
2101
|
+
return migration.list_backups()
|
|
2102
|
+
|
|
2103
|
+
def cleanup_old_backups(self, keep_count: int = 10) -> int:
|
|
2104
|
+
"""
|
|
2105
|
+
Clean up old backup files, keeping only the most recent ones.
|
|
2106
|
+
|
|
2107
|
+
Args:
|
|
2108
|
+
keep_count: Number of backups to keep
|
|
2109
|
+
|
|
2110
|
+
Returns:
|
|
2111
|
+
Number of backups deleted
|
|
2112
|
+
"""
|
|
2113
|
+
migration = ConfigMigration(self.config_path)
|
|
2114
|
+
return migration.cleanup_old_backups(keep_count)
|
|
2115
|
+
|
|
2116
|
+
def migrate_config_manually(self) -> Tuple[bool, List[str]]:
|
|
2117
|
+
"""
|
|
2118
|
+
Manually trigger configuration migration.
|
|
2119
|
+
|
|
2120
|
+
Returns:
|
|
2121
|
+
Tuple of (success, list of migration messages)
|
|
2122
|
+
"""
|
|
2123
|
+
migration = ConfigMigration(self.config_path)
|
|
2124
|
+
success, messages = migration.migrate_config()
|
|
2125
|
+
if success:
|
|
2126
|
+
# Reload configuration after migration
|
|
2127
|
+
self.config = None
|
|
2128
|
+
self.load_config()
|
|
2129
|
+
return success, messages
|
|
2130
|
+
|
|
2131
|
+
def check_migration_needed(self) -> bool:
|
|
2132
|
+
"""
|
|
2133
|
+
Check if configuration migration is needed.
|
|
2134
|
+
|
|
2135
|
+
Returns:
|
|
2136
|
+
True if migration is needed
|
|
2137
|
+
"""
|
|
2138
|
+
migration = ConfigMigration(self.config_path)
|
|
2139
|
+
return migration.needs_migration()
|
|
2140
|
+
|
|
2141
|
+
def get_config_health_report(self) -> Dict[str, Any]:
|
|
2142
|
+
"""
|
|
2143
|
+
Get a comprehensive health report of the configuration.
|
|
2144
|
+
|
|
2145
|
+
Returns:
|
|
2146
|
+
Dictionary with configuration health information
|
|
2147
|
+
"""
|
|
2148
|
+
try:
|
|
2149
|
+
config = self.get_config()
|
|
2150
|
+
validation_results = self.validate_config_comprehensive()
|
|
2151
|
+
|
|
2152
|
+
# Count errors by category
|
|
2153
|
+
error_counts = {}
|
|
2154
|
+
total_errors = 0
|
|
2155
|
+
for category, errors in validation_results.items():
|
|
2156
|
+
if isinstance(errors, list):
|
|
2157
|
+
error_counts[category] = len(errors)
|
|
2158
|
+
total_errors += len(errors)
|
|
2159
|
+
elif isinstance(errors, dict):
|
|
2160
|
+
# For nested categories like providers
|
|
2161
|
+
category_total = sum(
|
|
2162
|
+
len(provider_errors) for provider_errors in errors.values()
|
|
2163
|
+
)
|
|
2164
|
+
error_counts[category] = category_total
|
|
2165
|
+
total_errors += category_total
|
|
2166
|
+
|
|
2167
|
+
# Get provider health status
|
|
2168
|
+
provider_health = {}
|
|
2169
|
+
for provider_name in config.providers.keys():
|
|
2170
|
+
provider_health[provider_name] = {
|
|
2171
|
+
"enabled": config.providers[provider_name].enabled,
|
|
2172
|
+
"has_api_key": bool(config.providers[provider_name].api_key),
|
|
2173
|
+
"health_check_enabled": config.providers[
|
|
2174
|
+
provider_name
|
|
2175
|
+
].health_check_enabled,
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
return {
|
|
2179
|
+
"overall_health": ("healthy" if total_errors == 0 else "issues_found"),
|
|
2180
|
+
"total_errors": total_errors,
|
|
2181
|
+
"error_counts_by_category": error_counts,
|
|
2182
|
+
"validation_results": validation_results,
|
|
2183
|
+
"config_version": config.config_version,
|
|
2184
|
+
"migration_needed": self.check_migration_needed(),
|
|
2185
|
+
"provider_health": provider_health,
|
|
2186
|
+
"mcp_status": {
|
|
2187
|
+
"enabled": config.mcp.enabled,
|
|
2188
|
+
"servers_configured": len(config.mcp.servers),
|
|
2189
|
+
"servers_enabled": len(config.mcp.get_enabled_servers()),
|
|
2190
|
+
},
|
|
2191
|
+
"backup_info": {"available_backups": len(self.list_config_backups())},
|
|
2192
|
+
}
|
|
2193
|
+
except Exception as e:
|
|
2194
|
+
return {
|
|
2195
|
+
"overall_health": "error",
|
|
2196
|
+
"error": str(e),
|
|
2197
|
+
"total_errors": 1,
|
|
2198
|
+
"validation_results": {"general": [f"Health check failed: {e}"]},
|
|
2199
|
+
}
|