Coverage for src / osiris_cli / config.py: 0%
203 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-31 05:01 +0200
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-31 05:01 +0200
1import os
2import sys
3from pathlib import Path
4from typing import List, Optional
6import toml
7from pydantic import SecretStr
8from pydantic_settings import BaseSettings, SettingsConfigDict
10APP_NAME = "osiris"
11CONFIG_DIR = Path.home() / f".{APP_NAME}"
12CONFIG_FILE = CONFIG_DIR / "config.toml"
13HISTORY_DIR = CONFIG_DIR / "history"
14PROJECT_CONFIG_FILE = Path.cwd() / "osiris.toml" # New: Project-level config file
16DEFAULT_AUTO_ALLOW_TOOLS = ["read_file", "list_directory", "get_file_tree"]
17YOLO_AUTO_ALLOW_TOOLS = [
18 "run_shell_command",
19 "read_file",
20 "write_file",
21 "list_directory",
22 "get_file_tree",
23 "web_search",
24 "read_url",
25 "search_codebase",
26 "get_code_symbols",
27 "check_syntax",
28 "mcp_call",
29 "lsp_diagnostics",
30]
32class ProjectSettings(BaseSettings):
33 """Settings that can be overridden at the project level."""
34 provider: Optional[str] = None
35 default_model: Optional[str] = None
36 system_prompt: Optional[str] = None
37 yolo_mode: Optional[bool] = None
38 reasoning_mode: Optional[bool] = None
39 tracking_mode: Optional[str] = None
40 tracking_min_tools: Optional[int] = None
41 tracking_min_steps: Optional[int] = None
42 mcp_servers: Optional[dict] = None
43 mcp_bundles: Optional[dict] = None
45 model_config = SettingsConfigDict(extra="ignore") # Ignore extra fields from project config
47class Settings(BaseSettings):
48 # Schema
49 config_version: int = 1
51 # Provider Settings
52 provider: str = "ollama"
53 provider_base_url: Optional[str] = None # New: override for model-specific base urls
55 # API Keys (Secured)
56 openrouter_api_key: Optional[SecretStr] = None
57 openai_api_key: Optional[SecretStr] = None
58 gemini_api_key: Optional[SecretStr] = None
59 deepseek_api_key: Optional[SecretStr] = None
60 groq_api_key: Optional[SecretStr] = None
62 # Additional Providers API Keys
63 together_api_key: Optional[SecretStr] = None
64 mistral_api_key: Optional[SecretStr] = None
65 perplexity_api_key: Optional[SecretStr] = None
66 fireworks_api_key: Optional[SecretStr] = None
68 # Ollama Settings
69 ollama_base_url: str = "http://localhost:11434/v1"
71 # Model Preferences
72 default_model: str = "gpt-3.5-turbo"
74 # Parameters
75 temperature: float = 0.4
76 timeout: int = 600
77 max_context_chars: int = 32000
78 max_iterations: Optional[int] = None
79 default_tool_rounds: int = 5
80 tool_round_extension: int = 5
82 # UX
83 show_spinner: bool = True
84 syntax_highlighting: bool = True
85 syntax_theme: str = "monokai"
86 quantum_waterfall: bool = True
87 compact_hud: bool = False
88 yolo_mode: bool = False
89 reasoning_mode: bool = False
90 tracking_mode: str = "standard" # minimal | standard | verbose
91 tracking_min_tools: int = 2
92 tracking_min_steps: int = 2
93 system_prompt: str = "You are Osiris, an advanced AI terminal companion."
94 debug: bool = False
95 session_id: Optional[str] = None
97 # Interface preferences
98 interface_mode: Optional[str] = None # "cli" or "ui"
99 launch_count: int = 0 # Track how many times osiris has been launched
100 theme: str = "default" # UI theme: default, cyberpunk, minimal, ocean
102 # Safety
103 safety_profile: str = "strict" # strict | standard | yolo
104 auto_allow_tools: List[str] = [] # tools auto-allowed regardless of profile
105 session_allowlist: List[str] = [] # commands allowed for current session
107 # MCP (Model Context Protocol)
108 mcp_servers: dict = {} # e.g., {"filesystem": {"type": "stdio", "command": "node", "args": ["server.js"]}}
109 mcp_bundles: dict = {} # New: Bundles of MCP servers
110 mcp_enabled: bool = False
112 # LSP
113 lsp_enabled: bool = False
114 lsp_servers: dict = {} # e.g., {"python": {"command": "pyright-langserver", "args": ["--stdio"]}}
116 # Offline fallback
117 offline_fallback: bool = True
118 offline_provider: str = "ollama"
119 offline_model: str = "llama3.2"
121 def apply_defaults(self):
122 """Ensure defaults for first-run or missing settings."""
123 if self.auto_allow_tools is None:
124 self.auto_allow_tools = list(DEFAULT_AUTO_ALLOW_TOOLS)
125 if not self.safety_profile:
126 self.safety_profile = "strict"
127 if self.tracking_mode not in {"minimal", "standard", "verbose"}:
128 self.tracking_mode = "standard"
129 # Note: self.yolo_mode is set by safety_profile AND can be overridden by project config
130 if self.safety_profile == "yolo":
131 self.yolo_mode = True
132 if self.yolo_mode: # Apply YOLO tools if yolo_mode is True (from either global or project)
133 self.auto_allow_tools = list(YOLO_AUTO_ALLOW_TOOLS)
135 # Auto-discover standard env vars if keys missing
136 if not self.openai_api_key and os.getenv("OPENAI_API_KEY"):
137 self.openai_api_key = SecretStr(os.getenv("OPENAI_API_KEY") or "")
138 if not self.gemini_api_key and os.getenv("GEMINI_API_KEY"):
139 self.gemini_api_key = SecretStr(os.getenv("GEMINI_API_KEY") or "")
140 if not self.deepseek_api_key and os.getenv("DEEPSEEK_API_KEY"):
141 self.deepseek_api_key = SecretStr(os.getenv("DEEPSEEK_API_KEY") or "")
142 if not self.groq_api_key and os.getenv("GROQ_API_KEY"):
143 self.groq_api_key = SecretStr(os.getenv("GROQ_API_KEY") or "")
144 if not self.openrouter_api_key and os.getenv("OPENROUTER_API_KEY"):
145 self.openrouter_api_key = SecretStr(os.getenv("OPENROUTER_API_KEY") or "")
146 # Additional providers
147 if not self.together_api_key and os.getenv("TOGETHER_API_KEY"):
148 self.together_api_key = SecretStr(os.getenv("TOGETHER_API_KEY") or "")
149 if not self.mistral_api_key and os.getenv("MISTRAL_API_KEY"):
150 self.mistral_api_key = SecretStr(os.getenv("MISTRAL_API_KEY") or "")
151 if not self.perplexity_api_key and os.getenv("PERPLEXITY_API_KEY"):
152 self.perplexity_api_key = SecretStr(os.getenv("PERPLEXITY_API_KEY") or "")
153 if not self.fireworks_api_key and os.getenv("FIREWORKS_API_KEY"):
154 self.fireworks_api_key = SecretStr(os.getenv("FIREWORKS_API_KEY") or "")
156 # Auto-adjust temperature for models that don't support it
157 if hasattr(self, 'default_model') and self.default_model:
158 if "o1" in self.default_model.lower() or "reasoning" in self.default_model.lower():
159 self.temperature = 1.0
161 # Provision GitHub and Postgres MCP connectors by default when credentials exist.
162 servers = dict(self.mcp_servers or {})
163 if "github" not in servers and os.getenv("GITHUB_TOKEN"):
164 servers["github"] = {
165 "type": "http",
166 "url": "https://api.github.com/graphql",
167 "method": "POST",
168 "headers": {
169 "Authorization": "Bearer ${GITHUB_TOKEN}",
170 "Accept": "application/vnd.github+json",
171 },
172 "timeout": 20,
173 }
174 if "postgres" not in servers and os.getenv("POSTGRES_URL"):
175 script_path = Path(__file__).parent.parent / "scripts" / "postgres_mcp.py"
176 servers["postgres"] = {
177 "type": "stdio",
178 "command": sys.executable,
179 "args": [str(script_path)],
180 "env": {"POSTGRES_URL": os.getenv("POSTGRES_URL")},
181 "timeout": 30,
182 }
183 if "browser" not in servers and os.getenv("BROWSER_MCP_URL"):
184 script_path = Path(__file__).parent.parent / "scripts" / "browser_mcp.py"
185 servers["browser"] = {
186 "type": "stdio",
187 "command": sys.executable,
188 "args": [str(script_path)],
189 "env": {"REQUESTS_CA_BUNDLE": os.getenv("REQUESTS_CA_BUNDLE", "")},
190 "timeout": 20,
191 }
192 if "playwright" not in servers and os.getenv("PLAYWRIGHT_URL"):
193 script_path = Path(__file__).parent.parent / "scripts" / "playwright_mcp.py"
194 servers["playwright"] = {
195 "type": "stdio",
196 "command": sys.executable,
197 "args": [str(script_path)],
198 "env": {
199 "PLAYWRIGHT_URL": os.getenv("PLAYWRIGHT_URL"),
200 "PLAYWRIGHT_SCREENSHOT": os.getenv("PLAYWRIGHT_SCREENSHOT", "/tmp/osiris-ui.png"),
201 },
202 "timeout": 40,
203 }
204 if "slack" not in servers and os.getenv("SLACK_WEBHOOK_URL"):
205 script_path = Path(__file__).parent.parent / "scripts" / "slack_mcp.py"
206 servers["slack"] = {
207 "type": "stdio",
208 "command": sys.executable,
209 "args": [str(script_path)],
210 "env": {"SLACK_WEBHOOK_URL": os.getenv("SLACK_WEBHOOK_URL")},
211 "timeout": 15,
212 }
213 if "filesystem" not in servers and os.getenv("FILESYSTEM_MCP_ROOT"):
214 script_path = Path(__file__).parent.parent / "scripts" / "filesystem_mcp.py"
215 servers["filesystem"] = {
216 "type": "stdio",
217 "command": sys.executable,
218 "args": [str(script_path)],
219 "env": {"FILESYSTEM_MCP_ROOT": os.getenv("FILESYSTEM_MCP_ROOT")},
220 "timeout": 20,
221 }
222 if "monitoring" not in servers and os.getenv("MONITORING_URL"):
223 script_path = Path(__file__).parent.parent / "scripts" / "monitor_mcp.py"
224 servers["monitoring"] = {
225 "type": "stdio",
226 "command": sys.executable,
227 "args": [str(script_path)],
228 "env": {
229 "MONITORING_URL": os.getenv("MONITORING_URL"),
230 "MONITORING_API_KEY": os.getenv("MONITORING_API_KEY", ""),
231 },
232 "timeout": 15,
233 }
234 self.mcp_servers = servers
236 def set_safety_profile(self, profile: str):
237 self.safety_profile = profile
238 # Note: yolo_mode also influenced by project settings
239 if self.safety_profile == "yolo":
240 self.yolo_mode = True
241 else:
242 self.yolo_mode = False # Reset if not yolo profile
244 if self.yolo_mode:
245 self.auto_allow_tools = list(YOLO_AUTO_ALLOW_TOOLS)
246 elif not self.auto_allow_tools:
247 self.auto_allow_tools = list(DEFAULT_AUTO_ALLOW_TOOLS)
249 def save(self):
250 """Save current settings to config file"""
251 config_data = self.model_dump(exclude_none=True)
252 # Do not persist ephemeral session data.
253 config_data.pop("session_id", None)
254 # Persist API keys locally in config for all sessions.
255 for key in list(config_data.keys()):
256 if key.endswith("_api_key"):
257 value = getattr(self, key, None)
258 if value and hasattr(value, "get_secret_value"):
259 config_data[key] = value.get_secret_value()
260 elif value:
261 config_data[key] = value
262 else:
263 config_data.pop(key, None)
264 else:
265 value = config_data.get(key)
266 if isinstance(value, SecretStr):
267 config_data[key] = value.get_secret_value()
269 # Write to config file
270 CONFIG_DIR.mkdir(exist_ok=True)
271 with open(CONFIG_FILE, 'w') as f:
272 toml.dump(config_data, f)
274 try:
275 os.chmod(CONFIG_FILE, 0o600)
276 except Exception:
277 pass
279 def reload(self):
280 """Reload settings from config file"""
281 if CONFIG_FILE.exists():
282 config_data = toml.load(CONFIG_FILE)
283 for key, value in config_data.items():
284 if not hasattr(self, key):
285 continue
286 if key.endswith("_api_key") and value:
287 setattr(self, key, SecretStr(value))
288 else:
289 setattr(self, key, value)
290 self.apply_defaults()
293 def to_public_dict(self) -> dict:
294 """Return a sanitized dictionary with secrets masked for display/export."""
295 data = self.model_dump(exclude_none=True)
296 for k, v in list(data.items()):
297 if isinstance(v, SecretStr):
298 # Keep a signal that the key is set, without revealing value
299 data[k] = "****"
300 return data
302 model_config = SettingsConfigDict(env_prefix="OSIRIS_")
304# Known Providers Metadata
305KNOWN_PROVIDERS = {
306 "openrouter": {
307 "name": "OpenRouter",
308 "base_url": "https://openrouter.ai/api/v1",
309 "api_key_name": "openrouter_api_key",
310 "models": [
311 # Common FREE examples (subject to change; refresh for live list)
312 "amazon/nova-2-lite-v1:free",
313 "arcee-ai/trinity-mini:free",
314 "openai/gpt-oss-20b:free",
315 "allenai/olmo-3-32b-think:free",
316 "z-ai/glm-4.5-air:free",
317 "google/gemma-3n-e2b-it:free",
318 "qwen/qwen3-coder:free",
319 "nvidia/nemotron-nano-12b-v2-vl:free",
320 "x-ai/grok-4.1-fast:free",
321 # Representative paid models (availability depends on account)
322 "openai/gpt-4o",
323 "anthropic/claude-3.5-sonnet",
324 "deepseek/deepseek-r1",
325 "meta-llama/llama-3.1-70b-instruct",
326 "mistralai/mistral-large-2512"
327 ]
328 },
329 "openai": {
330 "name": "OpenAI",
331 "base_url": "https://api.openai.com/v1",
332 "api_key_name": "openai_api_key",
333 "models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo", "gpt-3.5-turbo-16k", "o1-preview", "o1-mini", "gpt-5.1-codex-mini"]
334 },
335 "gemini": {
336 "name": "Google Gemini",
337 "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
338 "api_key_name": "gemini_api_key",
339 "models": ["gemini-1.5-flash", "gemini-1.5-pro", "gemini-2.0-flash-exp"]
340 },
341 "deepseek": {
342 "name": "DeepSeek",
343 "base_url": "https://api.deepseek.com",
344 "api_key_name": "deepseek_api_key",
345 "models": ["deepseek-chat", "deepseek-reasoner"]
346 },
347 "together": {
348 "name": "Together AI",
349 "base_url": "https://api.together.xyz/v1",
350 "api_key_name": "together_api_key",
351 "models": []
352 },
353 "mistral": {
354 "name": "Mistral AI",
355 "base_url": "https://api.mistral.ai/v1",
356 "api_key_name": "mistral_api_key",
357 "models": []
358 },
359 "perplexity": {
360 "name": "Perplexity",
361 "base_url": "https://api.perplexity.ai",
362 "api_key_name": "perplexity_api_key",
363 "models": [
364 "sonar",
365 "sonar-pro",
366 "sonar-reasoning",
367 "sonar-reasoning-pro",
368 "sonar-deep-research"
369 ]
370 },
371 "fireworks": {
372 "name": "Fireworks AI",
373 "base_url": "https://api.fireworks.ai/inference/v1",
374 "api_key_name": "fireworks_api_key",
375 "models": []
376 },
377 "groq": {
378 "name": "Groq",
379 "base_url": "https://api.groq.com/openai/v1",
380 "api_key_name": "groq_api_key",
381 "models": ["llama-3.1-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768", "gemma2-9b-it"]
382 },
383 "ollama": {
384 "name": "Ollama (Local)",
385 "base_url": "http://localhost:11434/v1",
386 "api_key_name": None,
387 "models": [
388 "deepseek-r1:7b", "llama3.2", "qwen2.5-coder", "mistral-nemo", "gemma2", "phi3.5", "neural-chat"
389 ]
390 }
391}
393def load_config() -> Settings:
394 # Load global settings
395 global_settings = Settings()
396 if CONFIG_FILE.exists():
397 try:
398 with open(CONFIG_FILE, "r") as f:
399 data = toml.load(f)
400 global_settings = Settings(**data)
401 except Exception as e:
402 # Log error but proceed with defaults
403 print(f"Warning: Error loading global config file: {e}", file=sys.stderr)
404 pass
405 global_settings.apply_defaults() # Apply global defaults and env vars
407 # Load project-specific settings if osiris.toml exists in cwd
408 if PROJECT_CONFIG_FILE.exists():
409 try:
410 with open(PROJECT_CONFIG_FILE, "r") as f:
411 project_data = toml.load(f)
412 project_settings = ProjectSettings(**project_data)
414 # Merge project settings into global settings, project takes precedence
415 updated_fields = project_settings.model_dump(exclude_none=True)
416 for k, v in updated_fields.items():
417 if hasattr(global_settings, k):
418 setattr(global_settings, k, v)
420 # Re-apply defaults after merging project settings
421 # This is important for yolo_mode and auto_allow_tools which depend on safety_profile (which can be overridden)
422 global_settings.apply_defaults()
424 except Exception as e:
425 print(f"Warning: Error loading project config file {PROJECT_CONFIG_FILE}: {e}", file=sys.stderr)
426 pass
428 return global_settings
430settings = load_config()
432# Safety profile descriptors (informational)
433SAFETY_PROFILES = {
434 "strict": "Confirm every tool unless auto-allowed; safest default.",
435 "standard": "Auto-allow configured tools; prompt for others.",
436 "yolo": "Run tools without prompts.",
437}