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.
- smartify/__init__.py +3 -0
- smartify/agents/__init__.py +0 -0
- smartify/agents/adapters/__init__.py +13 -0
- smartify/agents/adapters/anthropic.py +253 -0
- smartify/agents/adapters/openai.py +289 -0
- smartify/api/__init__.py +26 -0
- smartify/api/auth.py +352 -0
- smartify/api/errors.py +380 -0
- smartify/api/events.py +345 -0
- smartify/api/server.py +992 -0
- smartify/cli/__init__.py +1 -0
- smartify/cli/main.py +430 -0
- smartify/engine/__init__.py +64 -0
- smartify/engine/approval.py +479 -0
- smartify/engine/orchestrator.py +1365 -0
- smartify/engine/scheduler.py +380 -0
- smartify/engine/spark.py +294 -0
- smartify/guardrails/__init__.py +22 -0
- smartify/guardrails/breakers.py +409 -0
- smartify/models/__init__.py +61 -0
- smartify/models/grid.py +625 -0
- smartify/notifications/__init__.py +22 -0
- smartify/notifications/webhook.py +556 -0
- smartify/state/__init__.py +46 -0
- smartify/state/checkpoint.py +558 -0
- smartify/state/resume.py +301 -0
- smartify/state/store.py +370 -0
- smartify/tools/__init__.py +17 -0
- smartify/tools/base.py +196 -0
- smartify/tools/builtin/__init__.py +79 -0
- smartify/tools/builtin/file.py +464 -0
- smartify/tools/builtin/http.py +195 -0
- smartify/tools/builtin/shell.py +137 -0
- smartify/tools/mcp/__init__.py +33 -0
- smartify/tools/mcp/adapter.py +157 -0
- smartify/tools/mcp/client.py +334 -0
- smartify/tools/mcp/registry.py +130 -0
- smartify/validator/__init__.py +0 -0
- smartify/validator/validate.py +271 -0
- smartify/workspace/__init__.py +5 -0
- smartify/workspace/manager.py +248 -0
- smartify_ai-0.1.0.dist-info/METADATA +201 -0
- smartify_ai-0.1.0.dist-info/RECORD +46 -0
- smartify_ai-0.1.0.dist-info/WHEEL +4 -0
- smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
- 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)}")
|