smartify-ai 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 (46) hide show
  1. smartify/__init__.py +3 -0
  2. smartify/agents/__init__.py +0 -0
  3. smartify/agents/adapters/__init__.py +13 -0
  4. smartify/agents/adapters/anthropic.py +253 -0
  5. smartify/agents/adapters/openai.py +289 -0
  6. smartify/api/__init__.py +26 -0
  7. smartify/api/auth.py +352 -0
  8. smartify/api/errors.py +380 -0
  9. smartify/api/events.py +345 -0
  10. smartify/api/server.py +992 -0
  11. smartify/cli/__init__.py +1 -0
  12. smartify/cli/main.py +430 -0
  13. smartify/engine/__init__.py +64 -0
  14. smartify/engine/approval.py +479 -0
  15. smartify/engine/orchestrator.py +1365 -0
  16. smartify/engine/scheduler.py +380 -0
  17. smartify/engine/spark.py +294 -0
  18. smartify/guardrails/__init__.py +22 -0
  19. smartify/guardrails/breakers.py +409 -0
  20. smartify/models/__init__.py +61 -0
  21. smartify/models/grid.py +625 -0
  22. smartify/notifications/__init__.py +22 -0
  23. smartify/notifications/webhook.py +556 -0
  24. smartify/state/__init__.py +46 -0
  25. smartify/state/checkpoint.py +558 -0
  26. smartify/state/resume.py +301 -0
  27. smartify/state/store.py +370 -0
  28. smartify/tools/__init__.py +17 -0
  29. smartify/tools/base.py +196 -0
  30. smartify/tools/builtin/__init__.py +79 -0
  31. smartify/tools/builtin/file.py +464 -0
  32. smartify/tools/builtin/http.py +195 -0
  33. smartify/tools/builtin/shell.py +137 -0
  34. smartify/tools/mcp/__init__.py +33 -0
  35. smartify/tools/mcp/adapter.py +157 -0
  36. smartify/tools/mcp/client.py +334 -0
  37. smartify/tools/mcp/registry.py +130 -0
  38. smartify/validator/__init__.py +0 -0
  39. smartify/validator/validate.py +271 -0
  40. smartify/workspace/__init__.py +5 -0
  41. smartify/workspace/manager.py +248 -0
  42. smartify_ai-0.1.0.dist-info/METADATA +201 -0
  43. smartify_ai-0.1.0.dist-info/RECORD +46 -0
  44. smartify_ai-0.1.0.dist-info/WHEEL +4 -0
  45. smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
  46. smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
smartify/api/auth.py ADDED
@@ -0,0 +1,352 @@
1
+ """API authentication for Smartify Runtime.
2
+
3
+ Supports multiple authentication methods:
4
+ - API Key (via header or query param)
5
+ - Bearer token (JWT, future)
6
+
7
+ Unauthenticated access is allowed only for specific endpoints like /health.
8
+ """
9
+
10
+ import hashlib
11
+ import hmac
12
+ import logging
13
+ import os
14
+ import secrets
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta
17
+ from typing import Callable, Dict, List, Optional, Set
18
+
19
+ from fastapi import Depends, HTTPException, Request, Security
20
+ from fastapi.security import APIKeyHeader, APIKeyQuery
21
+ from starlette.middleware.base import BaseHTTPMiddleware
22
+ from starlette.responses import JSONResponse
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ============================================================================
28
+ # Configuration
29
+ # ============================================================================
30
+
31
+ @dataclass
32
+ class AuthConfig:
33
+ """Authentication configuration."""
34
+
35
+ # Enable/disable auth (default: enabled if any keys configured)
36
+ enabled: bool = True
37
+
38
+ # API keys (can be set via SMARTIFY_API_KEYS env var, comma-separated)
39
+ api_keys: Set[str] = field(default_factory=set)
40
+
41
+ # Paths that don't require authentication
42
+ public_paths: Set[str] = field(default_factory=lambda: {
43
+ "/",
44
+ "/health",
45
+ "/docs",
46
+ "/openapi.json",
47
+ "/redoc",
48
+ })
49
+
50
+ # Path prefixes that don't require authentication
51
+ public_prefixes: List[str] = field(default_factory=lambda: [
52
+ "/docs",
53
+ "/redoc",
54
+ ])
55
+
56
+ # Rate limiting (per API key)
57
+ rate_limit_requests: int = 1000 # requests per window
58
+ rate_limit_window_seconds: int = 3600 # 1 hour
59
+
60
+ @classmethod
61
+ def from_env(cls) -> "AuthConfig":
62
+ """Load configuration from environment variables."""
63
+ config = cls()
64
+
65
+ # Load API keys from environment
66
+ api_keys_str = os.environ.get("SMARTIFY_API_KEYS", "")
67
+ if api_keys_str:
68
+ keys = [k.strip() for k in api_keys_str.split(",") if k.strip()]
69
+ config.api_keys = set(keys)
70
+ logger.info(f"Loaded {len(config.api_keys)} API key(s) from environment")
71
+
72
+ # Check if auth should be enabled
73
+ auth_enabled = os.environ.get("SMARTIFY_AUTH_ENABLED", "").lower()
74
+ if auth_enabled == "false":
75
+ config.enabled = False
76
+ logger.warning("Authentication disabled via SMARTIFY_AUTH_ENABLED=false")
77
+ elif not config.api_keys:
78
+ # No keys configured, disable auth with warning
79
+ config.enabled = False
80
+ logger.warning("No API keys configured - authentication disabled")
81
+
82
+ # Rate limit settings
83
+ rate_limit = os.environ.get("SMARTIFY_RATE_LIMIT")
84
+ if rate_limit:
85
+ try:
86
+ config.rate_limit_requests = int(rate_limit)
87
+ except ValueError:
88
+ pass
89
+
90
+ return config
91
+
92
+
93
+ # ============================================================================
94
+ # API Key Management
95
+ # ============================================================================
96
+
97
+ def generate_api_key(prefix: str = "sk") -> str:
98
+ """Generate a new random API key.
99
+
100
+ Format: {prefix}_{32_random_hex_chars}
101
+ Example: sk_a1b2c3d4e5f6789012345678901234567890
102
+ """
103
+ random_part = secrets.token_hex(16)
104
+ return f"{prefix}_{random_part}"
105
+
106
+
107
+ def hash_api_key(key: str) -> str:
108
+ """Hash an API key for secure storage.
109
+
110
+ Uses SHA-256 for consistent hashing.
111
+ """
112
+ return hashlib.sha256(key.encode()).hexdigest()
113
+
114
+
115
+ def verify_api_key(provided_key: str, stored_keys: Set[str]) -> bool:
116
+ """Verify an API key against stored keys.
117
+
118
+ Supports both plain keys and hashed keys.
119
+ Uses constant-time comparison to prevent timing attacks.
120
+ """
121
+ if not provided_key:
122
+ return False
123
+
124
+ # Direct match (for plain keys in development)
125
+ if provided_key in stored_keys:
126
+ return True
127
+
128
+ # Hash and compare (for production with hashed storage)
129
+ hashed = hash_api_key(provided_key)
130
+ for stored in stored_keys:
131
+ if hmac.compare_digest(hashed, stored):
132
+ return True
133
+
134
+ return False
135
+
136
+
137
+ # ============================================================================
138
+ # Rate Limiting
139
+ # ============================================================================
140
+
141
+ @dataclass
142
+ class RateLimitEntry:
143
+ """Track rate limit for a single key."""
144
+ count: int = 0
145
+ window_start: datetime = field(default_factory=datetime.now)
146
+
147
+
148
+ class RateLimiter:
149
+ """Simple in-memory rate limiter."""
150
+
151
+ def __init__(self, max_requests: int, window_seconds: int):
152
+ self.max_requests = max_requests
153
+ self.window_seconds = window_seconds
154
+ self._entries: Dict[str, RateLimitEntry] = {}
155
+
156
+ def check(self, key: str) -> tuple[bool, int]:
157
+ """Check if request is allowed.
158
+
159
+ Returns:
160
+ (allowed, remaining_requests)
161
+ """
162
+ now = datetime.now()
163
+ entry = self._entries.get(key)
164
+
165
+ if entry is None:
166
+ entry = RateLimitEntry(count=0, window_start=now)
167
+ self._entries[key] = entry
168
+
169
+ # Reset window if expired
170
+ window_end = entry.window_start + timedelta(seconds=self.window_seconds)
171
+ if now > window_end:
172
+ entry.count = 0
173
+ entry.window_start = now
174
+
175
+ # Check limit
176
+ if entry.count >= self.max_requests:
177
+ return False, 0
178
+
179
+ entry.count += 1
180
+ remaining = self.max_requests - entry.count
181
+ return True, remaining
182
+
183
+ def get_reset_time(self, key: str) -> Optional[datetime]:
184
+ """Get when the rate limit window resets."""
185
+ entry = self._entries.get(key)
186
+ if entry:
187
+ return entry.window_start + timedelta(seconds=self.window_seconds)
188
+ return None
189
+
190
+
191
+ # ============================================================================
192
+ # FastAPI Dependencies
193
+ # ============================================================================
194
+
195
+ # Security schemes
196
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
197
+ api_key_query = APIKeyQuery(name="api_key", auto_error=False)
198
+
199
+ # Global instances (initialized on startup)
200
+ _auth_config: Optional[AuthConfig] = None
201
+ _rate_limiter: Optional[RateLimiter] = None
202
+
203
+
204
+ def get_auth_config() -> AuthConfig:
205
+ """Get the global auth configuration."""
206
+ global _auth_config
207
+ if _auth_config is None:
208
+ _auth_config = AuthConfig.from_env()
209
+ return _auth_config
210
+
211
+
212
+ def get_rate_limiter() -> RateLimiter:
213
+ """Get the global rate limiter."""
214
+ global _rate_limiter
215
+ if _rate_limiter is None:
216
+ config = get_auth_config()
217
+ _rate_limiter = RateLimiter(
218
+ max_requests=config.rate_limit_requests,
219
+ window_seconds=config.rate_limit_window_seconds,
220
+ )
221
+ return _rate_limiter
222
+
223
+
224
+ async def verify_api_key_dep(
225
+ api_key_header: Optional[str] = Security(api_key_header),
226
+ api_key_query: Optional[str] = Security(api_key_query),
227
+ ) -> str:
228
+ """FastAPI dependency to verify API key.
229
+
230
+ Accepts key from either X-API-Key header or api_key query parameter.
231
+ """
232
+ config = get_auth_config()
233
+
234
+ if not config.enabled:
235
+ return "anonymous"
236
+
237
+ # Get key from header or query
238
+ api_key = api_key_header or api_key_query
239
+
240
+ if not api_key:
241
+ raise HTTPException(
242
+ status_code=401,
243
+ detail="API key required. Provide via X-API-Key header or api_key query parameter.",
244
+ headers={"WWW-Authenticate": "ApiKey"},
245
+ )
246
+
247
+ if not verify_api_key(api_key, config.api_keys):
248
+ raise HTTPException(
249
+ status_code=401,
250
+ detail="Invalid API key",
251
+ headers={"WWW-Authenticate": "ApiKey"},
252
+ )
253
+
254
+ # Check rate limit
255
+ limiter = get_rate_limiter()
256
+ allowed, remaining = limiter.check(api_key)
257
+
258
+ if not allowed:
259
+ reset_time = limiter.get_reset_time(api_key)
260
+ raise HTTPException(
261
+ status_code=429,
262
+ detail="Rate limit exceeded",
263
+ headers={
264
+ "X-RateLimit-Limit": str(config.rate_limit_requests),
265
+ "X-RateLimit-Remaining": "0",
266
+ "X-RateLimit-Reset": reset_time.isoformat() if reset_time else "",
267
+ },
268
+ )
269
+
270
+ return api_key
271
+
272
+
273
+ # ============================================================================
274
+ # Middleware
275
+ # ============================================================================
276
+
277
+ class AuthMiddleware(BaseHTTPMiddleware):
278
+ """Authentication middleware for FastAPI.
279
+
280
+ Checks API key for all requests except public paths.
281
+ """
282
+
283
+ async def dispatch(self, request: Request, call_next: Callable):
284
+ config = get_auth_config()
285
+
286
+ # Skip auth if disabled
287
+ if not config.enabled:
288
+ return await call_next(request)
289
+
290
+ # Check if path is public
291
+ path = request.url.path
292
+
293
+ if path in config.public_paths:
294
+ return await call_next(request)
295
+
296
+ for prefix in config.public_prefixes:
297
+ if path.startswith(prefix):
298
+ return await call_next(request)
299
+
300
+ # Extract API key
301
+ api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
302
+
303
+ if not api_key:
304
+ return JSONResponse(
305
+ status_code=401,
306
+ content={
307
+ "detail": "API key required. Provide via X-API-Key header or api_key query parameter."
308
+ },
309
+ headers={"WWW-Authenticate": "ApiKey"},
310
+ )
311
+
312
+ if not verify_api_key(api_key, config.api_keys):
313
+ return JSONResponse(
314
+ status_code=401,
315
+ content={"detail": "Invalid API key"},
316
+ headers={"WWW-Authenticate": "ApiKey"},
317
+ )
318
+
319
+ # Check rate limit
320
+ limiter = get_rate_limiter()
321
+ allowed, remaining = limiter.check(api_key)
322
+
323
+ if not allowed:
324
+ reset_time = limiter.get_reset_time(api_key)
325
+ return JSONResponse(
326
+ status_code=429,
327
+ content={"detail": "Rate limit exceeded"},
328
+ headers={
329
+ "X-RateLimit-Limit": str(config.rate_limit_requests),
330
+ "X-RateLimit-Remaining": "0",
331
+ "X-RateLimit-Reset": reset_time.isoformat() if reset_time else "",
332
+ },
333
+ )
334
+
335
+ # Add rate limit headers to response
336
+ response = await call_next(request)
337
+ response.headers["X-RateLimit-Limit"] = str(config.rate_limit_requests)
338
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
339
+
340
+ return response
341
+
342
+
343
+ # ============================================================================
344
+ # CLI Helpers
345
+ # ============================================================================
346
+
347
+ def print_new_api_key():
348
+ """Generate and print a new API key (for CLI use)."""
349
+ key = generate_api_key()
350
+ print(f"Generated API Key: {key}")
351
+ print(f"Add to environment: SMARTIFY_API_KEYS={key}")
352
+ print(f"Or add hashed: {hash_api_key(key)}")