atlas-chat 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.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1096 @@
1
+ """
2
+ Centralized configuration management using Pydantic models.
3
+
4
+ This module provides a unified configuration system that:
5
+ - Uses Pydantic for type validation and environment variable loading
6
+ - Replaces the duplicate config loading logic in config_utils.py
7
+ - Provides proper error handling with logging tracebacks
8
+ - Supports both .env files and direct environment variables
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Literal, Optional
17
+
18
+ import yaml
19
+ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
20
+ from pydantic_settings import BaseSettings
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def resolve_env_var(value: Optional[str], required: bool = True) -> Optional[str]:
26
+ """
27
+ Resolve environment variables in config values.
28
+
29
+ Supports patterns like:
30
+ - "${ENV_VAR_NAME}" -> replaced with os.environ.get("ENV_VAR_NAME")
31
+ - "literal-string" -> returned as-is
32
+ - None -> returned as-is
33
+
34
+ Note: Only complete env var patterns are resolved. Values like "prefix-${VAR}"
35
+ or "${VAR}-suffix" are treated as literals and returned unchanged.
36
+
37
+ Args:
38
+ value: Config value that may contain env var pattern
39
+ required: If True (default), raises ValueError if env var is not set.
40
+ If False, returns None when env var is not set.
41
+
42
+ Returns:
43
+ Resolved value with env vars substituted, or None if value is None
44
+ or if env var is not set and required=False
45
+
46
+ Raises:
47
+ ValueError: If env var pattern is found but variable is not set and required=True
48
+ """
49
+ if value is None:
50
+ return None
51
+
52
+ # Pattern: ${VAR_NAME}
53
+ # Uses fullmatch() to ensure the entire string is an env var pattern.
54
+ # Patterns like "${VAR}-suffix" or "prefix-${VAR}" are treated as literals.
55
+ pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
56
+ match = re.fullmatch(pattern, value)
57
+
58
+ if match:
59
+ env_var_name = match.group(1)
60
+ env_value = os.environ.get(env_var_name)
61
+
62
+ if env_value is None:
63
+ if required:
64
+ raise ValueError(
65
+ f"Environment variable '{env_var_name}' is not set but required in config"
66
+ )
67
+ return None
68
+
69
+ return env_value
70
+
71
+ # Return literal string if no pattern found
72
+ return value
73
+
74
+
75
+ class ModelConfig(BaseModel):
76
+ """Configuration for a single LLM model."""
77
+ model_name: str
78
+ model_url: str
79
+ api_key: str
80
+ description: Optional[str] = None
81
+ max_tokens: Optional[int] = 10000
82
+ temperature: Optional[float] = 0.7
83
+ # Optional extra HTTP headers (e.g. for providers like OpenRouter)
84
+ extra_headers: Optional[Dict[str, str]] = None
85
+ # Compliance/security level (e.g., "External", "Internal", "Public")
86
+ compliance_level: Optional[str] = None
87
+
88
+
89
+ class LLMConfig(BaseModel):
90
+ """Configuration for all LLM models."""
91
+ models: Dict[str, ModelConfig]
92
+
93
+ @field_validator('models', mode='before')
94
+ @classmethod
95
+ def validate_models(cls, v):
96
+ """Convert dict values to ModelConfig objects."""
97
+ if isinstance(v, dict):
98
+ return {name: ModelConfig(**config) if isinstance(config, dict) else config
99
+ for name, config in v.items()}
100
+ return v
101
+
102
+
103
+ class OAuthConfig(BaseModel):
104
+ """OAuth 2.1 configuration for MCP server authentication.
105
+
106
+ Supports the OAuth 2.1 Authorization Code Grant with PKCE as implemented
107
+ by FastMCP. See https://gofastmcp.com/clients/auth/oauth for details.
108
+ """
109
+ scopes: Optional[List[str]] = None # OAuth scopes to request (e.g., ["read", "write"])
110
+ client_name: str = "Atlas UI" # Client name for dynamic registration
111
+ callback_port: Optional[int] = None # Fixed port for OAuth callback (default: random)
112
+
113
+
114
+ class MCPServerConfig(BaseModel):
115
+ """Configuration for a single MCP server."""
116
+ description: Optional[str] = None
117
+ author: Optional[str] = None # Author of the MCP server
118
+ short_description: Optional[str] = None # Short description for marketplace display
119
+ help_email: Optional[str] = None # Contact email for help/support
120
+ groups: List[str] = Field(default_factory=list)
121
+ enabled: bool = True
122
+ command: Optional[List[str]] = None # Command to run server (for stdio servers)
123
+ cwd: Optional[str] = None # Working directory for command
124
+ env: Optional[Dict[str, str]] = None # Environment variables for stdio servers
125
+ url: Optional[str] = None # URL for HTTP servers
126
+ type: str = "stdio" # Server type: "stdio" or "http" (deprecated, use transport)
127
+ transport: Optional[str] = None # Explicit transport: "stdio", "http", "sse" - takes priority over auto-detection
128
+ # Authentication configuration
129
+ auth_type: str = "none" # Authentication type: "none", "api_key", "bearer", "jwt", "oauth"
130
+ auth_token: Optional[str] = None # Bearer token for MCP server authentication (supports ${ENV_VAR})
131
+ oauth_config: Optional[OAuthConfig] = None # OAuth 2.1 configuration (when auth_type="oauth")
132
+ compliance_level: Optional[str] = None # Compliance/security level (e.g., "SOC2", "HIPAA", "Public")
133
+ require_approval: List[str] = Field(default_factory=list) # List of tool names (without server prefix) requiring approval
134
+ allow_edit: List[str] = Field(default_factory=list) # LEGACY. List of tool names (without server prefix) allowing argument editing
135
+
136
+
137
+ class MCPConfig(BaseModel):
138
+ """Configuration for all MCP servers."""
139
+ servers: Dict[str, MCPServerConfig] = Field(default_factory=dict)
140
+
141
+ @field_validator('servers', mode='before')
142
+ @classmethod
143
+ def validate_servers(cls, v):
144
+ """Convert dict values to MCPServerConfig objects."""
145
+ if isinstance(v, dict):
146
+ return {name: MCPServerConfig(**config) if isinstance(config, dict) else config
147
+ for name, config in v.items()}
148
+ return v
149
+
150
+
151
+ class RAGSourceConfig(BaseModel):
152
+ """Configuration for a single RAG source (MCP or HTTP-based).
153
+
154
+ Supports two types:
155
+ - "mcp": MCP-based RAG server that exposes rag_discover_resources tool
156
+ - "http": HTTP REST API RAG server (like ATLAS RAG API)
157
+ """
158
+ type: Literal["mcp", "http"] = "mcp"
159
+
160
+ # Common fields
161
+ display_name: Optional[str] = None # UI display name
162
+ description: Optional[str] = None
163
+ icon: Optional[str] = None # UI icon
164
+ groups: List[str] = Field(default_factory=list) # Access groups
165
+ compliance_level: Optional[str] = None
166
+ enabled: bool = True
167
+
168
+ # MCP-specific fields (type="mcp")
169
+ command: Optional[List[str]] = None # Command for stdio MCP servers
170
+ cwd: Optional[str] = None # Working directory
171
+ env: Optional[Dict[str, str]] = None # Environment variables
172
+ url: Optional[str] = None # URL for HTTP/SSE MCP servers
173
+ transport: Optional[str] = None # "stdio", "http", "sse"
174
+ auth_token: Optional[str] = None # MCP server auth token
175
+
176
+ # HTTP REST API fields (type="http")
177
+ bearer_token: Optional[str] = None # Bearer token for HTTP RAG API
178
+ default_model: Optional[str] = None # Model for RAG queries
179
+ top_k: int = 4 # Number of documents to retrieve
180
+ timeout: float = 60.0 # Request timeout in seconds
181
+
182
+ # API endpoint customization (HTTP type)
183
+ discovery_endpoint: str = "/discover/datasources"
184
+ query_endpoint: str = "/rag/completions"
185
+
186
+ @model_validator(mode='after')
187
+ def validate_type_specific_fields(self):
188
+ """Validate that required fields are present based on type."""
189
+ if self.type == "mcp":
190
+ # MCP type requires either command (stdio) or url (http/sse)
191
+ if not self.command and not self.url:
192
+ raise ValueError("MCP RAG source requires either 'command' or 'url'")
193
+ elif self.type == "http":
194
+ # HTTP type requires url
195
+ if not self.url:
196
+ raise ValueError("HTTP RAG source requires 'url'")
197
+ return self
198
+
199
+
200
+ class RAGSourcesConfig(BaseModel):
201
+ """Configuration for all RAG sources."""
202
+ sources: Dict[str, RAGSourceConfig] = Field(default_factory=dict)
203
+
204
+ @field_validator('sources', mode='before')
205
+ @classmethod
206
+ def validate_sources(cls, v):
207
+ """Convert dict values to RAGSourceConfig objects."""
208
+ if isinstance(v, dict):
209
+ return {name: RAGSourceConfig(**config) if isinstance(config, dict) else config
210
+ for name, config in v.items()}
211
+ return v
212
+
213
+
214
+ class ToolApprovalConfig(BaseModel):
215
+ """Configuration for a single tool's approval settings."""
216
+ require_approval: bool = False
217
+ allow_edit: bool = True
218
+
219
+
220
+ class ToolApprovalsConfig(BaseModel):
221
+ """Configuration for tool approvals."""
222
+ require_approval_by_default: bool = False
223
+ tools: Dict[str, ToolApprovalConfig] = Field(default_factory=dict)
224
+
225
+ @field_validator('tools', mode='before')
226
+ @classmethod
227
+ def validate_tools(cls, v):
228
+ """Convert dict values to ToolApprovalConfig objects."""
229
+ if isinstance(v, dict):
230
+ return {name: ToolApprovalConfig(**config) if isinstance(config, dict) else config
231
+ for name, config in v.items()}
232
+ return v
233
+
234
+
235
+ class FileExtractorConfig(BaseModel):
236
+ """Configuration for a single file content extractor service."""
237
+ url: str
238
+ method: str = "POST"
239
+ timeout_seconds: int = 30
240
+ max_file_size_mb: int = 50
241
+ preview_chars: Optional[int] = 2000
242
+ request_format: str = "base64" # "base64", "multipart", or "url"
243
+ form_field_name: str = "file" # Field name for multipart form uploads
244
+ response_field: str = "text"
245
+ enabled: bool = True
246
+ # API key for authentication (supports ${ENV_VAR} syntax)
247
+ api_key: Optional[str] = None
248
+ # Additional HTTP headers (values support ${ENV_VAR} syntax)
249
+ headers: Optional[Dict[str, str]] = None
250
+
251
+
252
+ class FileExtractorsConfig(BaseModel):
253
+ """Configuration for file content extraction services."""
254
+ enabled: bool = True
255
+ default_behavior: str = "full" # "full" | "preview" | "none"
256
+ extractors: Dict[str, FileExtractorConfig] = Field(default_factory=dict)
257
+ extension_mapping: Dict[str, str] = Field(default_factory=dict)
258
+ mime_mapping: Dict[str, str] = Field(default_factory=dict)
259
+
260
+ @field_validator('default_behavior', mode='before')
261
+ @classmethod
262
+ def normalize_default_behavior(cls, v):
263
+ """Normalize legacy values to new 3-mode scheme."""
264
+ legacy_map = {"extract": "full", "attach_only": "none"}
265
+ return legacy_map.get(v, v)
266
+
267
+ @field_validator('extractors', mode='before')
268
+ @classmethod
269
+ def validate_extractors(cls, v):
270
+ """Convert dict values to FileExtractorConfig objects."""
271
+ if isinstance(v, dict):
272
+ return {name: FileExtractorConfig(**config) if isinstance(config, dict) else config
273
+ for name, config in v.items()}
274
+ return v
275
+
276
+
277
+ class AppSettings(BaseSettings):
278
+ """Main application settings loaded from environment variables."""
279
+
280
+ # Application settings
281
+ app_name: str = "Chat UI"
282
+ port: int = 8000
283
+ debug_mode: bool = False
284
+ # Logging settings
285
+ log_level: str = "INFO" # Override default logging level (DEBUG, INFO, WARNING, ERROR)
286
+ feature_metrics_logging_enabled: bool = Field(
287
+ False,
288
+ description="Enable metrics logging for user activities (LLM calls, tool calls, file uploads, errors)",
289
+ validation_alias=AliasChoices("FEATURE_METRICS_LOGGING_ENABLED"),
290
+ )
291
+ # Suppress LiteLLM verbose logging (independent of log_level)
292
+ feature_suppress_litellm_logging: bool = Field(
293
+ default=True,
294
+ description="Suppress LiteLLM verbose stdout/debug output by setting LITELLM_LOG=ERROR",
295
+ validation_alias=AliasChoices("FEATURE_SUPPRESS_LITELLM_LOGGING"),
296
+ )
297
+
298
+ # RAG Feature Flag
299
+ # When enabled, RAG sources are configured in config/overrides/rag-sources.json
300
+ # See docs/admin/external-rag-api.md for configuration details
301
+ feature_rag_enabled: bool = Field(
302
+ False,
303
+ description="Enable RAG (Retrieval-Augmented Generation). Configure sources in rag-sources.json",
304
+ validation_alias=AliasChoices("FEATURE_RAG_ENABLED"),
305
+ )
306
+
307
+ # Banner settings
308
+ banner_enabled: bool = False
309
+
310
+ # Splash screen settings
311
+ feature_splash_screen_enabled: bool = Field(
312
+ False,
313
+ description="Enable startup splash screen for displaying policies and information",
314
+ validation_alias=AliasChoices("FEATURE_SPLASH_SCREEN_ENABLED", "SPLASH_SCREEN_ENABLED"),
315
+ )
316
+
317
+ # Agent settings
318
+ # Renamed to feature_agent_mode_available to align with other FEATURE_* flags.
319
+ feature_agent_mode_available: bool = Field(
320
+ True,
321
+ description="Agent mode availability feature flag",
322
+ validation_alias=AliasChoices("FEATURE_AGENT_MODE_AVAILABLE", "AGENT_MODE_AVAILABLE")
323
+ ) # Accept both old and new env var names
324
+ agent_max_steps: int = 10
325
+ agent_loop_strategy: str = Field(
326
+ default="think-act",
327
+ description="Agent loop strategy selector (react, think-act)",
328
+ validation_alias=AliasChoices("AGENT_LOOP_STRATEGY"),
329
+ )
330
+ # Backward compatibility: support old AGENT_MODE_AVAILABLE env if present
331
+ @property
332
+ def agent_mode_available(self) -> bool:
333
+ """Maintain backward compatibility for code still referencing agent_mode_available."""
334
+ return self.feature_agent_mode_available
335
+
336
+ # Tool approval settings
337
+ require_tool_approval_by_default: bool = False
338
+ # When true, all tools require approval (admin-enforced), overriding per-tool and default settings
339
+ force_tool_approval_globally: bool = Field(default=False, validation_alias="FORCE_TOOL_APPROVAL_GLOBALLY")
340
+
341
+ # LLM Health Check settings
342
+ llm_health_check_interval: int = 5 # minutes
343
+
344
+ # MCP Health Check settings
345
+ mcp_health_check_interval: int = 300 # seconds (5 minutes)
346
+
347
+ # MCP Auto-Reconnect settings
348
+ feature_mcp_auto_reconnect_enabled: bool = Field(
349
+ False,
350
+ description="Enable automatic reconnection to failed MCP servers with exponential backoff",
351
+ validation_alias=AliasChoices("FEATURE_MCP_AUTO_RECONNECT_ENABLED"),
352
+ )
353
+ mcp_reconnect_interval: int = Field(
354
+ default=60,
355
+ description="Base interval in seconds between MCP reconnect attempts",
356
+ validation_alias="MCP_RECONNECT_INTERVAL"
357
+ )
358
+ mcp_reconnect_max_interval: int = Field(
359
+ default=300,
360
+ description="Maximum interval in seconds between MCP reconnect attempts (caps exponential backoff)",
361
+ validation_alias="MCP_RECONNECT_MAX_INTERVAL"
362
+ )
363
+ mcp_reconnect_backoff_multiplier: float = Field(
364
+ default=2.0,
365
+ description="Multiplier for exponential backoff between reconnect attempts",
366
+ validation_alias="MCP_RECONNECT_BACKOFF_MULTIPLIER"
367
+ )
368
+ mcp_discovery_timeout: int = Field(
369
+ default=30,
370
+ description="Timeout in seconds for MCP discovery calls (list_tools, list_prompts)",
371
+ validation_alias="MCP_DISCOVERY_TIMEOUT"
372
+ )
373
+ mcp_call_timeout: int = Field(
374
+ default=120,
375
+ description="Timeout in seconds for MCP tool calls (call_tool)",
376
+ validation_alias="MCP_CALL_TIMEOUT"
377
+ )
378
+
379
+ # MCP Token Storage settings
380
+ mcp_token_storage_dir: Optional[str] = Field(
381
+ default=None,
382
+ description="Directory for storing encrypted user tokens. Defaults to config/secure/",
383
+ validation_alias="MCP_TOKEN_STORAGE_DIR"
384
+ )
385
+ mcp_token_encryption_key: Optional[str] = Field(
386
+ default=None,
387
+ description="Encryption key for user tokens. If not set, tokens won't persist across restarts",
388
+ validation_alias="MCP_TOKEN_ENCRYPTION_KEY"
389
+ )
390
+
391
+ # Admin settings
392
+ admin_group: str = "admin"
393
+ test_user: str = "test@test.com" # Test user for development
394
+ auth_group_check_url: Optional[str] = Field(default=None, validation_alias="AUTH_GROUP_CHECK_URL")
395
+ auth_group_check_api_key: Optional[str] = Field(default=None, validation_alias="AUTH_GROUP_CHECK_API_KEY")
396
+
397
+ # Authentication header configuration
398
+ auth_user_header: str = Field(
399
+ default="X-User-Email",
400
+ description="HTTP header name to extract authenticated username from reverse proxy",
401
+ validation_alias="AUTH_USER_HEADER"
402
+ )
403
+
404
+ # Authentication header configuration
405
+ auth_user_header_type: str = Field(
406
+ default="email-string",
407
+ description="The datatype stored in AUTH_USER_HEADER",
408
+ validation_alias="AUTH_USER_HEADER_TYPE"
409
+ )
410
+
411
+ # Authentication AWS expected ALB ARN
412
+ auth_aws_expected_alb_arn: str = Field(
413
+ default="",
414
+ description="The expected AWS ALB ARN",
415
+ validation_alias="AUTH_AWS_EXPECTED_ALB_ARN"
416
+ )
417
+
418
+ # Authentication AWS region
419
+ auth_aws_region: str = Field(
420
+ default="us-east-1",
421
+ description="The AWS region",
422
+ validation_alias="AUTH_AWS_REGION"
423
+ )
424
+
425
+ # Proxy secret authentication configuration
426
+ feature_proxy_secret_enabled: bool = Field(
427
+ default=False,
428
+ description="Enable proxy secret validation to ensure requests come from trusted reverse proxy",
429
+ validation_alias="FEATURE_PROXY_SECRET_ENABLED"
430
+ )
431
+ proxy_secret_header: str = Field(
432
+ default="X-Proxy-Secret",
433
+ description="HTTP header name for proxy secret validation",
434
+ validation_alias="PROXY_SECRET_HEADER"
435
+ )
436
+ proxy_secret: Optional[str] = Field(
437
+ default=None,
438
+ description="Secret value that must be sent by reverse proxy for validation",
439
+ validation_alias="PROXY_SECRET"
440
+ )
441
+ auth_redirect_url: str = Field(
442
+ default="/auth",
443
+ description="URL to redirect to when authentication fails",
444
+ validation_alias="AUTH_REDIRECT_URL"
445
+ )
446
+
447
+ # S3/MinIO storage settings
448
+ use_mock_s3: bool = False # Use in-process S3 mock (no Docker required)
449
+ s3_endpoint: str = "http://localhost:9000"
450
+ s3_bucket_name: str = "atlas-files"
451
+ s3_access_key: str = "minioadmin"
452
+ s3_secret_key: str = "minioadmin"
453
+ s3_region: str = "us-east-1"
454
+ s3_timeout: int = 30
455
+ s3_use_ssl: bool = False
456
+
457
+ # Feature flags
458
+ feature_workspaces_enabled: bool = False
459
+ feature_tools_enabled: bool = False
460
+ feature_marketplace_enabled: bool = False
461
+ feature_files_panel_enabled: bool = False
462
+ feature_chat_history_enabled: bool = False
463
+ # Compliance level filtering feature gate
464
+ feature_compliance_levels_enabled: bool = Field(
465
+ False,
466
+ description="Enable compliance level filtering for MCP servers and data sources",
467
+ validation_alias=AliasChoices("FEATURE_COMPLIANCE_LEVELS_ENABLED"),
468
+ )
469
+ # Email domain whitelist feature gate
470
+ feature_domain_whitelist_enabled: bool = Field(
471
+ False,
472
+ description="Enable email domain whitelist restriction (configured in domain-whitelist.json)",
473
+ validation_alias=AliasChoices("FEATURE_DOMAIN_WHITELIST_ENABLED", "FEATURE_DOE_LAB_CHECK_ENABLED"),
474
+ )
475
+ # File content extraction feature gate
476
+ feature_file_content_extraction_enabled: bool = Field(
477
+ False,
478
+ description="Enable automatic content extraction from uploaded files (PDFs, images)",
479
+ validation_alias=AliasChoices("FEATURE_FILE_CONTENT_EXTRACTION_ENABLED"),
480
+ )
481
+
482
+ # Capability tokens (for headless access to downloads/iframes)
483
+ capability_token_secret: str = ""
484
+ capability_token_ttl_seconds: int = 3600
485
+
486
+ # Backend URL configuration for MCP server file access
487
+ # This should be the publicly accessible URL of the backend API
488
+ # Example: "https://atlas-ui.example.com" or "http://localhost:8000"
489
+ # If not set, relative URLs will be used (only works for local/stdio servers)
490
+ backend_public_url: Optional[str] = Field(
491
+ default=None,
492
+ description="Public URL of the backend API for file downloads by remote MCP servers",
493
+ validation_alias="BACKEND_PUBLIC_URL",
494
+ )
495
+
496
+ # Whether to include base64 file content as fallback in tool arguments
497
+ # This allows MCP servers to access files even if they cannot reach the backend URL
498
+ # WARNING: Enabling this can significantly increase message sizes for large files
499
+ include_file_content_base64: bool = Field(
500
+ default=False,
501
+ description="Include base64 encoded file content in tool arguments as fallback",
502
+ validation_alias="INCLUDE_FILE_CONTENT_BASE64",
503
+ )
504
+
505
+ # Rate limiting (global middleware)
506
+ rate_limit_rpm: int = Field(default=600, validation_alias="RATE_LIMIT_RPM")
507
+ rate_limit_window_seconds: int = Field(default=60, validation_alias="RATE_LIMIT_WINDOW_SECONDS")
508
+ rate_limit_per_path: bool = Field(default=False, validation_alias="RATE_LIMIT_PER_PATH")
509
+
510
+ # Security headers toggles (HSTS intentionally omitted)
511
+ security_csp_enabled: bool = Field(default=True, validation_alias="SECURITY_CSP_ENABLED")
512
+ security_csp_value: str | None = Field(
513
+ default="default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'self'",
514
+ validation_alias="SECURITY_CSP_VALUE",
515
+ )
516
+ security_xfo_enabled: bool = Field(default=True, validation_alias="SECURITY_XFO_ENABLED")
517
+ security_xfo_value: str = Field(default="SAMEORIGIN", validation_alias="SECURITY_XFO_VALUE")
518
+ security_nosniff_enabled: bool = Field(default=True, validation_alias="SECURITY_NOSNIFF_ENABLED")
519
+ security_referrer_policy_enabled: bool = Field(default=True, validation_alias="SECURITY_REFERRER_POLICY_ENABLED")
520
+ security_referrer_policy_value: str = Field(default="no-referrer", validation_alias="SECURITY_REFERRER_POLICY_VALUE")
521
+
522
+ # Prompt / template settings
523
+ prompt_base_path: str = "prompts" # Relative or absolute path to directory containing prompt templates
524
+ system_prompt_filename: str = "system_prompt.md" # Filename for system prompt template
525
+ tool_synthesis_prompt_filename: str = "tool_synthesis_prompt.md" # Filename for tool synthesis prompt template
526
+ # Agent prompts
527
+ agent_reason_prompt_filename: str = "agent_reason_prompt.md" # Filename for agent reason phase
528
+ agent_observe_prompt_filename: str = "agent_observe_prompt.md" # Filename for agent observe phase
529
+
530
+ # Config file names (can be overridden via environment variables)
531
+ mcp_config_file: str = Field(default="mcp.json", validation_alias="MCP_CONFIG_FILE")
532
+ rag_sources_config_file: str = Field(default="rag-sources.json", validation_alias="RAG_SOURCES_CONFIG_FILE")
533
+ llm_config_file: str = Field(default="llmconfig.yml", validation_alias="LLM_CONFIG_FILE")
534
+ help_config_file: str = Field(default="help-config.json", validation_alias="HELP_CONFIG_FILE")
535
+ messages_config_file: str = Field(default="messages.txt", validation_alias="MESSAGES_CONFIG_FILE")
536
+ tool_approvals_config_file: str = Field(default="tool-approvals.json", validation_alias="TOOL_APPROVALS_CONFIG_FILE")
537
+ splash_config_file: str = Field(default="splash-config.json", validation_alias="SPLASH_CONFIG_FILE")
538
+ file_extractors_config_file: str = Field(default="file-extractors.json", validation_alias="FILE_EXTRACTORS_CONFIG_FILE")
539
+
540
+ # Config directory paths
541
+ app_config_overrides: str = Field(default="config/overrides", validation_alias="APP_CONFIG_OVERRIDES")
542
+ app_config_defaults: str = Field(default="config/defaults", validation_alias="APP_CONFIG_DEFAULTS")
543
+
544
+ # Logging directory
545
+ app_log_dir: Optional[str] = Field(default=None, validation_alias="APP_LOG_DIR")
546
+
547
+ # Environment mode
548
+ environment: str = Field(default="production", validation_alias="ENVIRONMENT")
549
+
550
+ # Prompt injection risk thresholds
551
+ # NOT USED RIGHT NOW.
552
+ pi_threshold_low: int = Field(default=30, validation_alias="PI_THRESHOLD_LOW")
553
+ pi_threshold_medium: int = Field(default=50, validation_alias="PI_THRESHOLD_MEDIUM")
554
+ pi_threshold_high: int = Field(default=80, validation_alias="PI_THRESHOLD_HIGH")
555
+
556
+ # Runtime directories (relative to project root, not backend/)
557
+ runtime_feedback_dir: str = Field(default="../runtime/feedback", validation_alias="RUNTIME_FEEDBACK_DIR")
558
+
559
+ @model_validator(mode='after')
560
+ def validate_aws_alb_config(self):
561
+ """Validate that AWS ALB ARN is properly configured when using aws-alb-jwt auth."""
562
+ if self.auth_user_header_type == "aws-alb-jwt":
563
+ placeholder = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/your-alb-name/..."
564
+ if not self.auth_aws_expected_alb_arn or self.auth_aws_expected_alb_arn == placeholder:
565
+ raise ValueError(
566
+ "auth_aws_expected_alb_arn must be set to a valid AWS ALB ARN when auth_user_header_type is 'aws-alb-jwt'. "
567
+ "Current value is empty or a placeholder. Set AUTH_AWS_EXPECTED_ALB_ARN environment variable."
568
+ )
569
+ return self
570
+
571
+ model_config = {
572
+ "env_file": "../.env",
573
+ "env_file_encoding": "utf-8",
574
+ "extra": "ignore",
575
+ "env_prefix": "",
576
+ }
577
+
578
+
579
+ class ConfigManager:
580
+ """Centralized configuration manager with proper error handling."""
581
+
582
+ def __init__(self, backend_root: Optional[Path] = None):
583
+ self._backend_root = backend_root or Path(__file__).parent.parent.parent
584
+ self._app_settings: Optional[AppSettings] = None
585
+ self._llm_config: Optional[LLMConfig] = None
586
+ self._mcp_config: Optional[MCPConfig] = None
587
+ self._rag_mcp_config: Optional[MCPConfig] = None
588
+ self._rag_sources_config: Optional[RAGSourcesConfig] = None
589
+ self._tool_approvals_config: Optional[ToolApprovalsConfig] = None
590
+ self._file_extractors_config: Optional[FileExtractorsConfig] = None
591
+
592
+ def _search_paths(self, file_name: str) -> List[Path]:
593
+ """Generate common search paths for a configuration file.
594
+
595
+ Preferred layout uses project_root/config/overrides and project_root/config/defaults.
596
+ The backend process often runs with CWD=backend/, so relative paths like
597
+ "config/overrides" incorrectly resolve to backend/config/overrides (which doesn't exist).
598
+
599
+ Configuration settings can override these directories:
600
+ app_config_overrides, app_config_defaults (can be absolute or relative to project root)
601
+
602
+ Legacy fallbacks (backend/configfilesadmin, backend/configfiles) are preserved.
603
+ """
604
+ project_root = self._backend_root.parent # /workspaces/atlas-ui-3-11
605
+
606
+ # Use app_settings for config paths
607
+ overrides_env = self.app_settings.app_config_overrides
608
+ defaults_env = self.app_settings.app_config_defaults
609
+
610
+ overrides_root = Path(overrides_env)
611
+ defaults_root = Path(defaults_env)
612
+
613
+ # If provided paths are relative, interpret them relative to project root first.
614
+ if not overrides_root.is_absolute():
615
+ overrides_root_project = project_root / overrides_root
616
+ else:
617
+ overrides_root_project = overrides_root
618
+ if not defaults_root.is_absolute():
619
+ defaults_root_project = project_root / defaults_root
620
+ else:
621
+ defaults_root_project = defaults_root
622
+
623
+ # Legacy locations (inside backend)
624
+ legacy_admin = self._backend_root / "configfilesadmin" / file_name
625
+ legacy_defaults = self._backend_root / "configfiles" / file_name
626
+
627
+ # Build list including both CWD-relative (for backwards compat if running from project root)
628
+ # and project-root-relative variants. Deduplicate while preserving order.
629
+ candidates: List[Path] = [
630
+ overrides_root / file_name,
631
+ defaults_root / file_name,
632
+ overrides_root_project / file_name,
633
+ defaults_root_project / file_name,
634
+ legacy_admin,
635
+ legacy_defaults,
636
+ Path(file_name), # CWD
637
+ Path(f"../{file_name}"), # parent of CWD
638
+ project_root / file_name,
639
+ self._backend_root / file_name,
640
+ ]
641
+
642
+ seen = set()
643
+ search_paths: List[Path] = []
644
+ for p in candidates:
645
+ if p not in seen:
646
+ seen.add(p)
647
+ search_paths.append(p)
648
+
649
+ logger.debug(
650
+ "Config search paths for %s: %s", file_name, [str(p) for p in search_paths]
651
+ )
652
+ return search_paths
653
+
654
+ def _load_file_with_error_handling(self, file_paths: List[Path], file_type: str) -> Optional[Dict[str, Any]]:
655
+ """Load a file with comprehensive error handling and logging."""
656
+ for path in file_paths:
657
+ try:
658
+ if not path.exists():
659
+ continue
660
+
661
+ logger.info(f"Found {file_type} config at: {path.absolute()}")
662
+
663
+ with open(path, "r", encoding="utf-8") as f:
664
+ if file_type.lower() == "yaml":
665
+ data = yaml.safe_load(f)
666
+ elif file_type.lower() == "json":
667
+ data = json.load(f)
668
+ else:
669
+ raise ValueError(f"Unsupported file type: {file_type}")
670
+
671
+ if not isinstance(data, dict):
672
+ logger.error(
673
+ f"Invalid {file_type} format in {path}: expected dict, got {type(data)}",
674
+ exc_info=True
675
+ )
676
+ continue
677
+
678
+ logger.info(f"Successfully loaded {file_type} config from {path}")
679
+ return data
680
+
681
+ except (yaml.YAMLError, json.JSONDecodeError) as e:
682
+ logger.error(f"{file_type} parsing error in {path}: {e}", exc_info=True)
683
+ continue
684
+ except Exception as e:
685
+ logger.error(f"Unexpected error reading {path}: {e}", exc_info=True)
686
+ continue
687
+
688
+ logger.warning(f"{file_type} config not found in any of these locations: {[str(p) for p in file_paths]}")
689
+ return None
690
+
691
+ @property
692
+ def app_settings(self) -> AppSettings:
693
+ """Get application settings (cached)."""
694
+ if self._app_settings is None:
695
+ try:
696
+ self._app_settings = AppSettings()
697
+ logger.info("Application settings loaded successfully")
698
+ except Exception as e:
699
+ logger.error(f"Failed to load application settings: {e}", exc_info=True)
700
+ # Create default settings as fallback
701
+ self._app_settings = AppSettings()
702
+ return self._app_settings
703
+
704
+ @property
705
+ def llm_config(self) -> LLMConfig:
706
+ """Get LLM configuration (cached)."""
707
+ if self._llm_config is None:
708
+ try:
709
+ # Use config filename from app settings
710
+ llm_filename = self.app_settings.llm_config_file
711
+ file_paths = self._search_paths(llm_filename)
712
+ data = self._load_file_with_error_handling(file_paths, "YAML")
713
+
714
+ if data:
715
+ self._llm_config = LLMConfig(**data)
716
+ # Validate compliance levels
717
+ self._validate_llm_compliance_levels()
718
+ logger.info(f"Loaded {len(self._llm_config.models)} models from LLM config")
719
+ else:
720
+ self._llm_config = LLMConfig(models={})
721
+ logger.info("Created empty LLM config (no configuration file found)")
722
+
723
+ except Exception as e:
724
+ logger.error(f"Failed to parse LLM configuration: {e}", exc_info=True)
725
+ self._llm_config = LLMConfig(models={})
726
+
727
+ return self._llm_config
728
+
729
+ def _validate_llm_compliance_levels(self):
730
+ """Validate compliance levels for all LLM models."""
731
+ try:
732
+ # Standardize on running from atlas/ directory (agent_start.sh)
733
+ # Use non-prefixed imports so they resolve when cwd=backend
734
+ from atlas.core.compliance import get_compliance_manager
735
+ compliance_mgr = get_compliance_manager()
736
+
737
+ for model_name, model_config in self._llm_config.models.items():
738
+ if model_config.compliance_level:
739
+ validated = compliance_mgr.validate_compliance_level(
740
+ model_config.compliance_level,
741
+ context=f"for LLM model '{model_name}'"
742
+ )
743
+ # Update to canonical name or None if invalid
744
+ model_config.compliance_level = validated
745
+ except Exception as e:
746
+ logger.warning(f"Could not validate LLM compliance levels: {e}")
747
+
748
+ @property
749
+ def mcp_config(self) -> MCPConfig:
750
+ """Get MCP configuration (cached)."""
751
+ if self._mcp_config is None:
752
+ try:
753
+ # Use config filename from app settings
754
+ mcp_filename = self.app_settings.mcp_config_file
755
+ file_paths = self._search_paths(mcp_filename)
756
+ data = self._load_file_with_error_handling(file_paths, "JSON")
757
+
758
+ if data:
759
+ # Convert flat structure to nested structure for Pydantic
760
+ servers_data = {"servers": data}
761
+ self._mcp_config = MCPConfig(**servers_data)
762
+ # Validate compliance levels
763
+ self._validate_mcp_compliance_levels(self._mcp_config, "MCP")
764
+ logger.info(f"Loaded MCP config with {len(self._mcp_config.servers)} servers: {list(self._mcp_config.servers.keys())}")
765
+ else:
766
+ self._mcp_config = MCPConfig()
767
+ logger.info("Created empty MCP config (no configuration file found)")
768
+
769
+ except Exception as e:
770
+ logger.error(f"Failed to parse MCP configuration: {e}", exc_info=True)
771
+ self._mcp_config = MCPConfig()
772
+
773
+ return self._mcp_config
774
+
775
+ @property
776
+ def rag_mcp_config(self) -> MCPConfig:
777
+ """Get RAG MCP configuration (cached) derived from rag-sources.json.
778
+
779
+ Extracts MCP-type sources from rag_sources_config and converts them
780
+ to MCPServerConfig format for compatibility with RAGMCPService.
781
+ Returns an empty config when FEATURE_RAG_ENABLED is false.
782
+ """
783
+ if not self.app_settings.feature_rag_enabled:
784
+ if self._rag_mcp_config is None:
785
+ self._rag_mcp_config = MCPConfig()
786
+ return self._rag_mcp_config
787
+
788
+ if self._rag_mcp_config is None:
789
+ try:
790
+ # Get all RAG sources and filter to MCP type only
791
+ rag_sources = self.rag_sources_config
792
+ mcp_servers: Dict[str, MCPServerConfig] = {}
793
+
794
+ for name, source in rag_sources.sources.items():
795
+ if source.type != "mcp":
796
+ continue
797
+ if not source.enabled:
798
+ continue
799
+
800
+ # Convert RAGSourceConfig to MCPServerConfig
801
+ mcp_servers[name] = MCPServerConfig(
802
+ description=source.description,
803
+ groups=source.groups,
804
+ enabled=source.enabled,
805
+ command=source.command,
806
+ cwd=source.cwd,
807
+ env=source.env,
808
+ url=source.url,
809
+ transport=source.transport,
810
+ auth_token=source.auth_token,
811
+ compliance_level=source.compliance_level,
812
+ )
813
+
814
+ self._rag_mcp_config = MCPConfig(servers=mcp_servers)
815
+
816
+ if mcp_servers:
817
+ # Validate compliance levels
818
+ self._validate_mcp_compliance_levels(self._rag_mcp_config, "RAG MCP")
819
+ logger.info(
820
+ "Loaded RAG MCP config with %d servers from rag-sources.json: %s",
821
+ len(mcp_servers),
822
+ list(mcp_servers.keys())
823
+ )
824
+ else:
825
+ logger.info("No MCP-type RAG sources found in rag-sources.json")
826
+
827
+ except Exception as e:
828
+ logger.error("Failed to build RAG MCP configuration: %s", e, exc_info=True)
829
+ self._rag_mcp_config = MCPConfig()
830
+
831
+ return self._rag_mcp_config
832
+
833
+ @property
834
+ def rag_sources_config(self) -> RAGSourcesConfig:
835
+ """Get unified RAG sources configuration (cached) from rag-sources.json.
836
+
837
+ This config supports both MCP-based and HTTP REST API RAG sources.
838
+ Returns an empty config when FEATURE_RAG_ENABLED is false.
839
+ """
840
+ if not self.app_settings.feature_rag_enabled:
841
+ if self._rag_sources_config is None:
842
+ self._rag_sources_config = RAGSourcesConfig()
843
+ logger.info("RAG sources config skipped (FEATURE_RAG_ENABLED=false)")
844
+ return self._rag_sources_config
845
+
846
+ if self._rag_sources_config is None:
847
+ try:
848
+ rag_filename = self.app_settings.rag_sources_config_file
849
+ file_paths = self._search_paths(rag_filename)
850
+ data = self._load_file_with_error_handling(file_paths, "JSON")
851
+
852
+ if data:
853
+ sources_data = {"sources": data}
854
+ self._rag_sources_config = RAGSourcesConfig(**sources_data)
855
+ # Validate compliance levels
856
+ self._validate_rag_sources_compliance_levels(self._rag_sources_config)
857
+ logger.info(
858
+ "Loaded RAG sources config with %d sources: %s",
859
+ len(self._rag_sources_config.sources),
860
+ list(self._rag_sources_config.sources.keys())
861
+ )
862
+ else:
863
+ self._rag_sources_config = RAGSourcesConfig()
864
+ logger.info("Created empty RAG sources config (no configuration file found)")
865
+
866
+ except Exception as e:
867
+ logger.error("Failed to parse RAG sources configuration: %s", e, exc_info=True)
868
+ self._rag_sources_config = RAGSourcesConfig()
869
+
870
+ return self._rag_sources_config
871
+
872
+ def _validate_rag_sources_compliance_levels(self, config: RAGSourcesConfig) -> None:
873
+ """Validate that RAG source compliance levels are defined."""
874
+ from atlas.core.compliance import get_compliance_manager
875
+ try:
876
+ compliance_mgr = get_compliance_manager()
877
+ for source_name, source_config in config.sources.items():
878
+ level = source_config.compliance_level
879
+ if level and not compliance_mgr.is_valid_level(level):
880
+ logger.warning(
881
+ "RAG source '%s' has unknown compliance level: %s",
882
+ source_name,
883
+ level
884
+ )
885
+ except Exception as e:
886
+ logger.debug("Compliance validation skipped for RAG sources: %s", e)
887
+
888
+ @property
889
+ def tool_approvals_config(self) -> ToolApprovalsConfig:
890
+ """Get tool approvals configuration built from mcp.json and env variables (cached)."""
891
+ if self._tool_approvals_config is None:
892
+ try:
893
+ # Get default from environment
894
+ default_require_approval = self.app_settings.require_tool_approval_by_default
895
+
896
+ # Build tool-specific configs from MCP servers (Option B):
897
+ # Only include entries explicitly listed under require_approval.
898
+ tools_config: Dict[str, ToolApprovalConfig] = {}
899
+
900
+ for server_name, server_config in self.mcp_config.servers.items():
901
+ require_approval_list = server_config.require_approval or []
902
+
903
+ for tool_name in require_approval_list:
904
+ full_tool_name = f"{server_name}_{tool_name}"
905
+ # Mark as explicitly requiring approval; allow_edit is moot for requirement
906
+ tools_config[full_tool_name] = ToolApprovalConfig(
907
+ require_approval=True,
908
+ allow_edit=True # UI always allows edits; keep True for compatibility
909
+ )
910
+
911
+ self._tool_approvals_config = ToolApprovalsConfig(
912
+ require_approval_by_default=default_require_approval,
913
+ tools=tools_config
914
+ )
915
+ logger.info(f"Built tool approvals config from mcp.json with {len(tools_config)} tool-specific settings (default: {default_require_approval})")
916
+
917
+ except Exception as e:
918
+ logger.error(f"Failed to build tool approvals configuration: {e}", exc_info=True)
919
+ self._tool_approvals_config = ToolApprovalsConfig()
920
+
921
+ return self._tool_approvals_config
922
+
923
+ @property
924
+ def file_extractors_config(self) -> FileExtractorsConfig:
925
+ """Get file extractors configuration (cached)."""
926
+ if self._file_extractors_config is None:
927
+ try:
928
+ extractors_filename = self.app_settings.file_extractors_config_file
929
+ file_paths = self._search_paths(extractors_filename)
930
+ data = self._load_file_with_error_handling(file_paths, "JSON")
931
+
932
+ if data:
933
+ self._file_extractors_config = FileExtractorsConfig(**data)
934
+ # Resolve environment variables in extractor configs
935
+ self._resolve_file_extractor_env_vars()
936
+ logger.info(
937
+ f"Loaded file extractors config with {len(self._file_extractors_config.extractors)} extractors"
938
+ )
939
+ else:
940
+ # Return disabled config if file not found
941
+ self._file_extractors_config = FileExtractorsConfig(enabled=False)
942
+ logger.info("File extractors config not found, using disabled defaults")
943
+
944
+ except Exception as e:
945
+ logger.error(f"Failed to parse file extractors configuration: {e}", exc_info=True)
946
+ self._file_extractors_config = FileExtractorsConfig(enabled=False)
947
+
948
+ return self._file_extractors_config
949
+
950
+ def _resolve_file_extractor_env_vars(self) -> None:
951
+ """Resolve environment variables in file extractor configurations.
952
+
953
+ Supports ${ENV_VAR} syntax for:
954
+ - url: Extractor service URL (required - extractor disabled if not set)
955
+ - api_key: Authentication API key (optional - None if not set)
956
+ - headers: Header values (optional - omitted if not set)
957
+ """
958
+ if self._file_extractors_config is None:
959
+ return
960
+
961
+ for extractor_name, extractor in self._file_extractors_config.extractors.items():
962
+ try:
963
+ # Resolve URL if it contains env var pattern (required)
964
+ if extractor.url:
965
+ resolved_url = resolve_env_var(extractor.url, required=True)
966
+ if resolved_url != extractor.url:
967
+ extractor.url = resolved_url
968
+ logger.debug(f"Resolved URL env var for extractor '{extractor_name}'")
969
+
970
+ except ValueError as e:
971
+ logger.error(f"Failed to resolve URL env var for extractor '{extractor_name}': {e}")
972
+ # Disable the extractor if URL env var resolution fails
973
+ extractor.enabled = False
974
+ continue
975
+
976
+ # Resolve API key if it contains env var pattern (optional)
977
+ if extractor.api_key:
978
+ resolved_key = resolve_env_var(extractor.api_key, required=False)
979
+ if resolved_key is None:
980
+ logger.debug(f"API key env var not set for extractor '{extractor_name}', will make unauthenticated requests")
981
+ extractor.api_key = None
982
+ elif resolved_key != extractor.api_key:
983
+ extractor.api_key = resolved_key
984
+ logger.debug(f"Resolved API key env var for extractor '{extractor_name}'")
985
+
986
+ # Resolve header values if they contain env var patterns (optional)
987
+ if extractor.headers:
988
+ resolved_headers = {}
989
+ for header_name, header_value in extractor.headers.items():
990
+ resolved_value = resolve_env_var(header_value, required=False)
991
+ if resolved_value is not None:
992
+ resolved_headers[header_name] = resolved_value
993
+ if resolved_value != header_value:
994
+ logger.debug(f"Resolved header '{header_name}' env var for extractor '{extractor_name}'")
995
+ else:
996
+ logger.debug(f"Header '{header_name}' env var not set for extractor '{extractor_name}', omitting header")
997
+ extractor.headers = resolved_headers if resolved_headers else None
998
+
999
+ def _validate_mcp_compliance_levels(self, config: MCPConfig, config_type: str):
1000
+ """Validate compliance levels for all MCP servers."""
1001
+ try:
1002
+ # Standardize on running from atlas/ directory (agent_start.sh)
1003
+ from atlas.core.compliance import get_compliance_manager
1004
+ compliance_mgr = get_compliance_manager()
1005
+
1006
+ for server_name, server_config in config.servers.items():
1007
+ if server_config.compliance_level:
1008
+ validated = compliance_mgr.validate_compliance_level(
1009
+ server_config.compliance_level,
1010
+ context=f"for {config_type} server '{server_name}'"
1011
+ )
1012
+ # Update to canonical name or None if invalid
1013
+ server_config.compliance_level = validated
1014
+ except Exception as e:
1015
+ logger.warning(f"Could not validate {config_type} compliance levels: {e}")
1016
+
1017
+ def reload_configs(self) -> None:
1018
+ """Reload all configurations from files."""
1019
+ self._app_settings = None
1020
+ self._llm_config = None
1021
+ self._mcp_config = None
1022
+ self._rag_mcp_config = None
1023
+ self._rag_sources_config = None
1024
+ self._tool_approvals_config = None
1025
+ self._file_extractors_config = None
1026
+ logger.info("Configuration cache cleared, will reload on next access")
1027
+
1028
+ def reload_mcp_config(self) -> MCPConfig:
1029
+ """Reload MCP configuration from disk.
1030
+
1031
+ This clears the cached MCP config and forces a reload from the config file.
1032
+ Used for hot-reloading MCP server configuration without restarting the application.
1033
+
1034
+ Returns:
1035
+ The newly loaded MCPConfig
1036
+ """
1037
+ self._mcp_config = None
1038
+ self._tool_approvals_config = None # Also clear tool approvals since they depend on MCP
1039
+ logger.info("MCP configuration cache cleared, reloading from disk")
1040
+ return self.mcp_config
1041
+
1042
+ def validate_config(self) -> Dict[str, bool]:
1043
+ """Validate all configurations and return status."""
1044
+ status = {}
1045
+
1046
+ try:
1047
+ self.app_settings
1048
+ status["app_settings"] = True
1049
+ except Exception as e:
1050
+ logger.error(f"App settings validation failed: {e}", exc_info=True)
1051
+ status["app_settings"] = False
1052
+
1053
+ try:
1054
+ llm_config = self.llm_config
1055
+ status["llm_config"] = len(llm_config.models) > 0
1056
+ if not status["llm_config"]:
1057
+ logger.warning("LLM config is valid but contains no models")
1058
+ except Exception as e:
1059
+ logger.error(f"LLM config validation failed: {e}", exc_info=True)
1060
+ status["llm_config"] = False
1061
+
1062
+ try:
1063
+ mcp_config = self.mcp_config
1064
+ status["mcp_config"] = len(mcp_config.servers) > 0
1065
+ if not status["mcp_config"]:
1066
+ logger.warning("MCP config is valid but contains no servers")
1067
+ except Exception as e:
1068
+ logger.error(f"MCP config validation failed: {e}", exc_info=True)
1069
+ status["mcp_config"] = False
1070
+
1071
+ return status
1072
+
1073
+
1074
+ # Global configuration manager instance
1075
+ config_manager = ConfigManager()
1076
+
1077
+
1078
+ # Convenience functions for easy access
1079
+ def get_app_settings() -> AppSettings:
1080
+ """Get application settings."""
1081
+ return config_manager.app_settings
1082
+
1083
+
1084
+ def get_llm_config() -> LLMConfig:
1085
+ """Get LLM configuration."""
1086
+ return config_manager.llm_config
1087
+
1088
+
1089
+ def get_mcp_config() -> MCPConfig:
1090
+ """Get MCP configuration."""
1091
+ return config_manager.mcp_config
1092
+
1093
+
1094
+ def get_file_extractors_config() -> FileExtractorsConfig:
1095
+ """Get file extractors configuration."""
1096
+ return config_manager.file_extractors_config