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

1import os 

2import sys 

3from pathlib import Path 

4from typing import List, Optional 

5 

6import toml 

7from pydantic import SecretStr 

8from pydantic_settings import BaseSettings, SettingsConfigDict 

9 

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 

15 

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] 

31 

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 

44 

45 model_config = SettingsConfigDict(extra="ignore") # Ignore extra fields from project config 

46 

47class Settings(BaseSettings): 

48 # Schema 

49 config_version: int = 1 

50 

51 # Provider Settings 

52 provider: str = "ollama" 

53 provider_base_url: Optional[str] = None # New: override for model-specific base urls 

54 

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 

61 

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 

67 

68 # Ollama Settings 

69 ollama_base_url: str = "http://localhost:11434/v1" 

70 

71 # Model Preferences 

72 default_model: str = "gpt-3.5-turbo" 

73 

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 

81 

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 

96 

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 

101 

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 

106 

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 

111 

112 # LSP 

113 lsp_enabled: bool = False 

114 lsp_servers: dict = {} # e.g., {"python": {"command": "pyright-langserver", "args": ["--stdio"]}} 

115 

116 # Offline fallback 

117 offline_fallback: bool = True 

118 offline_provider: str = "ollama" 

119 offline_model: str = "llama3.2" 

120 

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) 

134 

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 "") 

155 

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 

160 

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 

235 

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 

243 

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) 

248 

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() 

268 

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) 

273 

274 try: 

275 os.chmod(CONFIG_FILE, 0o600) 

276 except Exception: 

277 pass 

278 

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() 

291 

292 

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 

301 

302 model_config = SettingsConfigDict(env_prefix="OSIRIS_") 

303 

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} 

392 

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 

406 

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) 

413 

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) 

419 

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() 

423 

424 except Exception as e: 

425 print(f"Warning: Error loading project config file {PROJECT_CONFIG_FILE}: {e}", file=sys.stderr) 

426 pass 

427 

428 return global_settings 

429 

430settings = load_config() 

431 

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}