Spaces:
Building
Building
Update admin_routes.py
Browse files- admin_routes.py +282 -238
admin_routes.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
"""Admin API endpoints for Flare
|
2 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
3 |
-
Provides authentication, project, version, and API management endpoints.
|
4 |
"""
|
5 |
|
6 |
import os
|
@@ -27,8 +27,8 @@ from encryption_utils import encrypt, decrypt
|
|
27 |
# ===================== JWT Config =====================
|
28 |
def get_jwt_config():
|
29 |
"""Get JWT configuration based on environment"""
|
30 |
-
# Check if
|
31 |
-
if os.
|
32 |
# Cloud mode - use secrets from environment
|
33 |
jwt_secret = os.getenv("JWT_SECRET")
|
34 |
if not jwt_secret:
|
@@ -73,6 +73,7 @@ class EnvironmentUpdate(BaseModel):
|
|
73 |
llm_provider: ProviderSettingsUpdate
|
74 |
tts_provider: ProviderSettingsUpdate
|
75 |
stt_provider: ProviderSettingsUpdate
|
|
|
76 |
|
77 |
class ProjectCreate(BaseModel):
|
78 |
name: str
|
@@ -80,100 +81,101 @@ class ProjectCreate(BaseModel):
|
|
80 |
icon: Optional[str] = "folder"
|
81 |
description: Optional[str] = ""
|
82 |
default_locale: str = "tr"
|
83 |
-
supported_locales: List[str] = ["tr"]
|
84 |
-
|
|
|
|
|
85 |
class ProjectUpdate(BaseModel):
|
86 |
-
caption:
|
87 |
-
icon: Optional[str] =
|
88 |
-
description: Optional[str] =
|
89 |
-
default_locale:
|
90 |
-
supported_locales:
|
91 |
-
|
|
|
|
|
|
|
92 |
class VersionCreate(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
93 |
caption: Optional[str] = ""
|
94 |
-
|
95 |
-
|
96 |
-
|
|
|
|
|
|
|
97 |
|
98 |
class VersionUpdate(BaseModel):
|
99 |
-
caption:
|
100 |
-
general_prompt:
|
101 |
-
llm:
|
102 |
-
intents:
|
|
|
103 |
|
104 |
class APICreate(BaseModel):
|
105 |
name: str
|
106 |
url: str
|
107 |
-
method: str = "
|
108 |
-
headers: Dict[str,
|
109 |
body_template: Dict[str, Any] = {}
|
110 |
timeout_seconds: int = 10
|
|
|
|
|
111 |
auth: Optional[Dict[str, Any]] = None
|
112 |
response_prompt: Optional[str] = None
|
|
|
113 |
|
114 |
class APIUpdate(BaseModel):
|
115 |
-
url:
|
116 |
-
method:
|
117 |
-
headers:
|
118 |
-
body_template:
|
119 |
-
timeout_seconds:
|
120 |
-
|
121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
122 |
|
123 |
# ===================== Auth Helpers =====================
|
124 |
def create_token(username: str) -> str:
|
125 |
-
"""Create JWT token"""
|
126 |
-
|
|
|
127 |
|
128 |
payload = {
|
129 |
"sub": username,
|
130 |
-
"exp":
|
131 |
"iat": datetime.now(timezone.utc)
|
132 |
}
|
133 |
|
134 |
-
return jwt.encode(payload,
|
135 |
|
136 |
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
137 |
"""Verify JWT token and return username"""
|
138 |
-
|
|
|
139 |
|
140 |
try:
|
141 |
-
payload = jwt.decode(
|
142 |
-
|
143 |
-
jwt_config["secret"],
|
144 |
-
algorithms=[jwt_config["algorithm"]]
|
145 |
-
)
|
146 |
-
username = payload.get("sub")
|
147 |
-
if not username:
|
148 |
-
raise HTTPException(status_code=401, detail="Invalid token")
|
149 |
-
return username
|
150 |
except jwt.ExpiredSignatureError:
|
151 |
raise HTTPException(status_code=401, detail="Token expired")
|
152 |
except jwt.InvalidTokenError:
|
153 |
raise HTTPException(status_code=401, detail="Invalid token")
|
154 |
|
155 |
-
|
156 |
-
"""Verify password against hash"""
|
157 |
-
# For bcrypt hashes
|
158 |
-
if hashed_password.startswith("$2b$"):
|
159 |
-
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
160 |
-
|
161 |
-
# For SHA256 hashes (legacy)
|
162 |
-
password_with_salt = plain_password + salt
|
163 |
-
password_hash = hashlib.sha256(password_with_salt.encode()).hexdigest()
|
164 |
-
return password_hash == hashed_password
|
165 |
-
|
166 |
-
def hash_password(password: str) -> tuple[str, str]:
|
167 |
-
"""Hash password and return (hash, salt)"""
|
168 |
-
# Use bcrypt for new passwords
|
169 |
-
salt = bcrypt.gensalt()
|
170 |
-
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
171 |
-
return hashed.decode('utf-8'), "" # bcrypt includes salt in hash
|
172 |
-
|
173 |
-
# ===================== Login Endpoints =====================
|
174 |
@router.post("/login", response_model=LoginResponse)
|
175 |
async def login(request: LoginRequest):
|
176 |
-
"""User login"""
|
177 |
cfg = ConfigProvider.get()
|
178 |
|
179 |
# Find user
|
@@ -182,13 +184,13 @@ async def login(request: LoginRequest):
|
|
182 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
183 |
|
184 |
# Verify password
|
185 |
-
if not
|
186 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
187 |
|
188 |
# Create token
|
189 |
token = create_token(request.username)
|
190 |
|
191 |
-
log(f"✅ User '{request.username}' logged in")
|
192 |
return LoginResponse(token=token, username=request.username)
|
193 |
|
194 |
@router.post("/change-password")
|
@@ -205,18 +207,19 @@ async def change_password(
|
|
205 |
raise HTTPException(status_code=404, detail="User not found")
|
206 |
|
207 |
# Verify current password
|
208 |
-
if not
|
209 |
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
210 |
|
211 |
-
#
|
212 |
-
|
|
|
213 |
|
214 |
# Update user
|
215 |
-
user.password_hash = new_hash
|
216 |
-
user.salt =
|
217 |
|
218 |
-
# Save
|
219 |
-
|
220 |
|
221 |
log(f"✅ Password changed for user '{username}'")
|
222 |
return {"success": True}
|
@@ -252,15 +255,16 @@ async def get_locale_details(
|
|
252 |
# ===================== Environment Endpoints =====================
|
253 |
@router.get("/environment")
|
254 |
async def get_environment(username: str = Depends(verify_token)):
|
255 |
-
"""Get environment configuration"""
|
256 |
cfg = ConfigProvider.get()
|
257 |
env_config = cfg.global_config
|
258 |
|
259 |
return {
|
260 |
-
"llm_provider": env_config.llm_provider.model_dump(),
|
261 |
-
"tts_provider": env_config.tts_provider.model_dump(),
|
262 |
-
"stt_provider": env_config.stt_provider.model_dump(),
|
263 |
-
"providers": [p.model_dump() for p in env_config.providers]
|
|
|
264 |
}
|
265 |
|
266 |
@router.put("/environment")
|
@@ -268,7 +272,7 @@ async def update_environment(
|
|
268 |
update: EnvironmentUpdate,
|
269 |
username: str = Depends(verify_token)
|
270 |
):
|
271 |
-
"""Update environment configuration"""
|
272 |
log(f"📝 Updating environment config by {username}")
|
273 |
|
274 |
cfg = ConfigProvider.get()
|
@@ -278,36 +282,41 @@ async def update_environment(
|
|
278 |
if not llm_provider_def:
|
279 |
raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
|
280 |
|
281 |
-
# Validate requirements
|
282 |
if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
|
283 |
-
raise HTTPException(status_code=400, detail=f"API key
|
284 |
|
285 |
if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
|
286 |
-
raise HTTPException(status_code=400, detail=f"
|
287 |
-
|
288 |
# Validate TTS provider
|
289 |
tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
|
290 |
-
if not tts_provider_def
|
291 |
raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
|
292 |
|
293 |
-
if tts_provider_def
|
294 |
-
raise HTTPException(status_code=400, detail=f"API key
|
295 |
|
296 |
# Validate STT provider
|
297 |
stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
|
298 |
-
if not stt_provider_def
|
299 |
raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
|
300 |
|
301 |
-
if stt_provider_def
|
302 |
-
raise HTTPException(status_code=400, detail=f"API key
|
303 |
|
304 |
# Update via ConfigProvider
|
305 |
ConfigProvider.update_environment(update.model_dump(), username)
|
306 |
|
307 |
-
log(f"✅ Environment updated by {username}")
|
308 |
return {"success": True}
|
309 |
|
310 |
# ===================== Project Endpoints =====================
|
|
|
|
|
|
|
|
|
|
|
|
|
311 |
@router.get("/projects")
|
312 |
async def list_projects(
|
313 |
include_deleted: bool = False,
|
@@ -323,31 +332,49 @@ async def list_projects(
|
|
323 |
|
324 |
return [p.model_dump() for p in projects]
|
325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
326 |
@router.post("/projects")
|
327 |
async def create_project(
|
328 |
project: ProjectCreate,
|
329 |
username: str = Depends(verify_token)
|
330 |
):
|
331 |
-
"""Create new project"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
332 |
new_project = ConfigProvider.create_project(project.model_dump(), username)
|
333 |
|
334 |
log(f"✅ Project '{project.name}' created by {username}")
|
335 |
return new_project.model_dump()
|
336 |
|
337 |
-
@router.get("/projects/{project_id}")
|
338 |
-
async def get_project(
|
339 |
-
project_id: int,
|
340 |
-
username: str = Depends(verify_token)
|
341 |
-
):
|
342 |
-
"""Get project details"""
|
343 |
-
cfg = ConfigProvider.get()
|
344 |
-
project = next((p for p in cfg.projects if p.id == project_id), None)
|
345 |
-
|
346 |
-
if not project:
|
347 |
-
raise HTTPException(status_code=404, detail="Project not found")
|
348 |
-
|
349 |
-
return project.model_dump()
|
350 |
-
|
351 |
@router.put("/projects/{project_id}")
|
352 |
async def update_project(
|
353 |
project_id: int,
|
@@ -355,9 +382,22 @@ async def update_project(
|
|
355 |
username: str = Depends(verify_token)
|
356 |
):
|
357 |
"""Update project"""
|
358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
359 |
|
360 |
-
|
|
|
|
|
|
|
361 |
return updated_project.model_dump()
|
362 |
|
363 |
@router.delete("/projects/{project_id}")
|
@@ -365,16 +405,16 @@ async def delete_project(project_id: int, username: str = Depends(verify_token))
|
|
365 |
"""Delete project (soft delete)"""
|
366 |
ConfigProvider.delete_project(project_id, username)
|
367 |
|
368 |
-
log(f"✅ Project
|
369 |
return {"success": True}
|
370 |
|
371 |
@router.patch("/projects/{project_id}/toggle")
|
372 |
async def toggle_project(project_id: int, username: str = Depends(verify_token)):
|
373 |
"""Toggle project enabled status"""
|
374 |
-
|
375 |
|
376 |
-
log(f"✅ Project {
|
377 |
-
return {"
|
378 |
|
379 |
# ===================== Version Endpoints =====================
|
380 |
@router.get("/projects/{project_id}/versions")
|
@@ -384,9 +424,7 @@ async def list_versions(
|
|
384 |
username: str = Depends(verify_token)
|
385 |
):
|
386 |
"""List project versions"""
|
387 |
-
|
388 |
-
project = next((p for p in cfg.projects if p.id == project_id), None)
|
389 |
-
|
390 |
if not project:
|
391 |
raise HTTPException(status_code=404, detail="Project not found")
|
392 |
|
@@ -418,7 +456,7 @@ async def update_version(
|
|
418 |
username: str = Depends(verify_token)
|
419 |
):
|
420 |
"""Update version"""
|
421 |
-
updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(
|
422 |
|
423 |
log(f"✅ Version {version_id} updated for project {project_id} by {username}")
|
424 |
return updated_version.model_dump()
|
@@ -434,7 +472,16 @@ async def publish_version(
|
|
434 |
|
435 |
log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
|
436 |
|
437 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
438 |
|
439 |
return {"success": True}
|
440 |
|
@@ -450,72 +497,6 @@ async def delete_version(
|
|
450 |
log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
|
451 |
return {"success": True}
|
452 |
|
453 |
-
# ===================== Test Endpoints =====================
|
454 |
-
@router.post("/test-connection")
|
455 |
-
async def test_connection(
|
456 |
-
request: dict = Body(...),
|
457 |
-
username: str = Depends(verify_token)
|
458 |
-
):
|
459 |
-
"""Test connection to LLM endpoint"""
|
460 |
-
endpoint = request.get("endpoint", "").rstrip("/")
|
461 |
-
api_key = request.get("api_key", "")
|
462 |
-
provider_name = request.get("provider", "spark")
|
463 |
-
|
464 |
-
if not endpoint:
|
465 |
-
raise HTTPException(status_code=400, detail="Endpoint is required")
|
466 |
-
|
467 |
-
# For Spark variants
|
468 |
-
if provider_name in ("spark", "spark_cloud", "spark_onpremise"):
|
469 |
-
try:
|
470 |
-
headers = {
|
471 |
-
"Authorization": f"Bearer {api_key}",
|
472 |
-
"Content-Type": "application/json"
|
473 |
-
}
|
474 |
-
|
475 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
476 |
-
response = await client.get(f"{endpoint}/health", headers=headers)
|
477 |
-
|
478 |
-
if response.status_code == 200:
|
479 |
-
return {"success": True, "message": "Connection successful"}
|
480 |
-
else:
|
481 |
-
return {"success": False, "message": f"Health check failed: {response.status_code}"}
|
482 |
-
|
483 |
-
except Exception as e:
|
484 |
-
log(f"❌ Connection test failed: {e}")
|
485 |
-
return {"success": False, "message": str(e)}
|
486 |
-
|
487 |
-
# For other providers
|
488 |
-
return {"success": True, "message": "Provider doesn't require connection test"}
|
489 |
-
|
490 |
-
@router.post("/validate/regex")
|
491 |
-
async def validate_regex(
|
492 |
-
request: dict = Body(...),
|
493 |
-
username: str = Depends(verify_token)
|
494 |
-
):
|
495 |
-
"""Validate regex pattern"""
|
496 |
-
pattern = request.get("pattern", "")
|
497 |
-
test_value = request.get("test_value", "")
|
498 |
-
|
499 |
-
try:
|
500 |
-
import re
|
501 |
-
compiled_regex = re.compile(pattern)
|
502 |
-
matches = bool(compiled_regex.match(test_value))
|
503 |
-
|
504 |
-
return {
|
505 |
-
"valid": True,
|
506 |
-
"matches": matches,
|
507 |
-
"pattern": pattern,
|
508 |
-
"test_value": test_value
|
509 |
-
}
|
510 |
-
except Exception as e:
|
511 |
-
return {
|
512 |
-
"valid": False,
|
513 |
-
"matches": False,
|
514 |
-
"error": str(e),
|
515 |
-
"pattern": pattern,
|
516 |
-
"test_value": test_value
|
517 |
-
}
|
518 |
-
|
519 |
# ===================== API Endpoints =====================
|
520 |
@router.get("/apis")
|
521 |
async def list_apis(
|
@@ -547,7 +528,7 @@ async def update_api(
|
|
547 |
username: str = Depends(verify_token)
|
548 |
):
|
549 |
"""Update API"""
|
550 |
-
updated_api = ConfigProvider.update_api(api_name, update.model_dump(
|
551 |
|
552 |
log(f"✅ API '{api_name}' updated by {username}")
|
553 |
return updated_api.model_dump()
|
@@ -560,87 +541,150 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
|
|
560 |
log(f"✅ API '{api_name}' deleted by {username}")
|
561 |
return {"success": True}
|
562 |
|
563 |
-
@router.post("/
|
564 |
-
async def
|
565 |
request: dict = Body(...),
|
566 |
username: str = Depends(verify_token)
|
567 |
):
|
568 |
-
"""
|
569 |
-
|
570 |
-
|
571 |
-
api_config = request.get("api_config")
|
572 |
-
test_params = request.get("test_params", {})
|
573 |
-
|
574 |
-
if not api_config:
|
575 |
-
raise HTTPException(status_code=400, detail="API config is required")
|
576 |
|
577 |
try:
|
578 |
-
|
|
|
|
|
|
|
579 |
return {
|
580 |
-
"
|
581 |
-
"
|
582 |
-
"
|
583 |
-
"
|
584 |
-
"duration_ms": result.get("duration_ms")
|
585 |
}
|
586 |
except Exception as e:
|
587 |
-
log(f"❌ API test failed: {e}")
|
588 |
return {
|
589 |
-
"
|
590 |
-
"
|
|
|
|
|
|
|
591 |
}
|
592 |
|
593 |
-
# =====================
|
594 |
-
@router.
|
595 |
-
async def
|
596 |
-
|
597 |
-
offset: int = Query(0, ge=0),
|
598 |
username: str = Depends(verify_token)
|
599 |
):
|
600 |
-
"""
|
601 |
-
|
|
|
|
|
|
|
602 |
return {
|
603 |
-
"
|
604 |
-
"
|
605 |
-
"
|
606 |
-
"
|
|
|
|
|
|
|
607 |
}
|
608 |
|
609 |
-
|
610 |
-
|
611 |
-
|
612 |
-
|
|
|
|
|
|
|
613 |
return {
|
614 |
-
"
|
615 |
-
"
|
616 |
-
"
|
|
|
|
|
|
|
|
|
|
|
617 |
}
|
618 |
|
619 |
-
# =====================
|
620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
621 |
|
622 |
-
|
623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
624 |
while True:
|
625 |
try:
|
626 |
-
|
627 |
-
|
|
|
|
|
|
|
628 |
|
629 |
-
|
630 |
-
|
|
|
|
|
|
|
631 |
|
|
|
|
|
|
|
|
|
|
|
632 |
except Exception as e:
|
633 |
-
log(f"❌
|
|
|
|
|
|
|
634 |
|
635 |
def start_cleanup_task():
|
636 |
-
"""Start
|
637 |
-
|
638 |
-
|
639 |
-
|
640 |
-
_cleanup_thread.start()
|
641 |
-
log("🧹 Started cleanup task")
|
642 |
-
|
643 |
-
# ===================== Helper Functions =====================
|
644 |
-
def _get_iso_timestamp() -> str:
|
645 |
-
"""Get current timestamp in ISO format"""
|
646 |
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
|
1 |
+
"""Admin API endpoints for Flare (Refactored)
|
2 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
3 |
+
Provides authentication, project, version, and API management endpoints with provider support.
|
4 |
"""
|
5 |
|
6 |
import os
|
|
|
27 |
# ===================== JWT Config =====================
|
28 |
def get_jwt_config():
|
29 |
"""Get JWT configuration based on environment"""
|
30 |
+
# Check if we're in HuggingFace Space
|
31 |
+
if os.getenv("SPACE_ID"):
|
32 |
# Cloud mode - use secrets from environment
|
33 |
jwt_secret = os.getenv("JWT_SECRET")
|
34 |
if not jwt_secret:
|
|
|
73 |
llm_provider: ProviderSettingsUpdate
|
74 |
tts_provider: ProviderSettingsUpdate
|
75 |
stt_provider: ProviderSettingsUpdate
|
76 |
+
parameter_collection_config: Optional[Dict[str, Any]] = None
|
77 |
|
78 |
class ProjectCreate(BaseModel):
|
79 |
name: str
|
|
|
81 |
icon: Optional[str] = "folder"
|
82 |
description: Optional[str] = ""
|
83 |
default_locale: str = "tr"
|
84 |
+
supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
|
85 |
+
timezone: str = "Europe/Istanbul"
|
86 |
+
region: str = "tr-TR"
|
87 |
+
|
88 |
class ProjectUpdate(BaseModel):
|
89 |
+
caption: str
|
90 |
+
icon: Optional[str] = "folder"
|
91 |
+
description: Optional[str] = ""
|
92 |
+
default_locale: str = "tr"
|
93 |
+
supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
|
94 |
+
timezone: str = "Europe/Istanbul"
|
95 |
+
region: str = "tr-TR"
|
96 |
+
last_update_date: str
|
97 |
+
|
98 |
class VersionCreate(BaseModel):
|
99 |
+
caption: str
|
100 |
+
source_version_id: int | None = None
|
101 |
+
|
102 |
+
class IntentModel(BaseModel):
|
103 |
+
name: str
|
104 |
caption: Optional[str] = ""
|
105 |
+
detection_prompt: str
|
106 |
+
examples: List[Dict[str, str]] = [] # LocalizedExample format
|
107 |
+
parameters: List[Dict[str, Any]] = []
|
108 |
+
action: str
|
109 |
+
fallback_timeout_prompt: Optional[str] = None
|
110 |
+
fallback_error_prompt: Optional[str] = None
|
111 |
|
112 |
class VersionUpdate(BaseModel):
|
113 |
+
caption: str
|
114 |
+
general_prompt: str
|
115 |
+
llm: Dict[str, Any]
|
116 |
+
intents: List[IntentModel]
|
117 |
+
last_update_date: str
|
118 |
|
119 |
class APICreate(BaseModel):
|
120 |
name: str
|
121 |
url: str
|
122 |
+
method: str = "POST"
|
123 |
+
headers: Dict[str, str] = {}
|
124 |
body_template: Dict[str, Any] = {}
|
125 |
timeout_seconds: int = 10
|
126 |
+
retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
|
127 |
+
proxy: Optional[str] = None
|
128 |
auth: Optional[Dict[str, Any]] = None
|
129 |
response_prompt: Optional[str] = None
|
130 |
+
response_mappings: List[Dict[str, Any]] = []
|
131 |
|
132 |
class APIUpdate(BaseModel):
|
133 |
+
url: str
|
134 |
+
method: str
|
135 |
+
headers: Dict[str, str]
|
136 |
+
body_template: Dict[str, Any]
|
137 |
+
timeout_seconds: int
|
138 |
+
retry: Dict[str, Any]
|
139 |
+
proxy: Optional[str]
|
140 |
+
auth: Optional[Dict[str, Any]]
|
141 |
+
response_prompt: Optional[str]
|
142 |
+
response_mappings: List[Dict[str, Any]] = []
|
143 |
+
last_update_date: str
|
144 |
+
|
145 |
+
class TestRequest(BaseModel):
|
146 |
+
test_type: str # "all", "ui", "backend", "integration", "spark"
|
147 |
|
148 |
# ===================== Auth Helpers =====================
|
149 |
def create_token(username: str) -> str:
|
150 |
+
"""Create JWT token for user"""
|
151 |
+
config = get_jwt_config()
|
152 |
+
expiry = datetime.now(timezone.utc) + timedelta(hours=config["expiration_hours"])
|
153 |
|
154 |
payload = {
|
155 |
"sub": username,
|
156 |
+
"exp": expiry,
|
157 |
"iat": datetime.now(timezone.utc)
|
158 |
}
|
159 |
|
160 |
+
return jwt.encode(payload, config["secret"], algorithm=config["algorithm"])
|
161 |
|
162 |
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
163 |
"""Verify JWT token and return username"""
|
164 |
+
token = credentials.credentials
|
165 |
+
config = get_jwt_config()
|
166 |
|
167 |
try:
|
168 |
+
payload = jwt.decode(token, config["secret"], algorithms=[config["algorithm"]])
|
169 |
+
return payload["sub"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
170 |
except jwt.ExpiredSignatureError:
|
171 |
raise HTTPException(status_code=401, detail="Token expired")
|
172 |
except jwt.InvalidTokenError:
|
173 |
raise HTTPException(status_code=401, detail="Invalid token")
|
174 |
|
175 |
+
# ===================== Auth Endpoints =====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
@router.post("/login", response_model=LoginResponse)
|
177 |
async def login(request: LoginRequest):
|
178 |
+
"""User login endpoint"""
|
179 |
cfg = ConfigProvider.get()
|
180 |
|
181 |
# Find user
|
|
|
184 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
185 |
|
186 |
# Verify password
|
187 |
+
if not bcrypt.checkpw(request.password.encode('utf-8'), user.password_hash.encode('utf-8')):
|
188 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
189 |
|
190 |
# Create token
|
191 |
token = create_token(request.username)
|
192 |
|
193 |
+
log(f"✅ User '{request.username}' logged in successfully")
|
194 |
return LoginResponse(token=token, username=request.username)
|
195 |
|
196 |
@router.post("/change-password")
|
|
|
207 |
raise HTTPException(status_code=404, detail="User not found")
|
208 |
|
209 |
# Verify current password
|
210 |
+
if not bcrypt.checkpw(request.current_password.encode('utf-8'), user.password_hash.encode('utf-8')):
|
211 |
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
212 |
|
213 |
+
# Generate new password hash
|
214 |
+
salt = bcrypt.gensalt()
|
215 |
+
new_hash = bcrypt.hashpw(request.new_password.encode('utf-8'), salt)
|
216 |
|
217 |
# Update user
|
218 |
+
user.password_hash = new_hash.decode('utf-8')
|
219 |
+
user.salt = salt.decode('utf-8')
|
220 |
|
221 |
+
# Save configuration
|
222 |
+
cfg.save()
|
223 |
|
224 |
log(f"✅ Password changed for user '{username}'")
|
225 |
return {"success": True}
|
|
|
255 |
# ===================== Environment Endpoints =====================
|
256 |
@router.get("/environment")
|
257 |
async def get_environment(username: str = Depends(verify_token)):
|
258 |
+
"""Get environment configuration with provider info"""
|
259 |
cfg = ConfigProvider.get()
|
260 |
env_config = cfg.global_config
|
261 |
|
262 |
return {
|
263 |
+
"llm_provider": env_config.llm_provider.model_dump() if env_config.llm_provider else None,
|
264 |
+
"tts_provider": env_config.tts_provider.model_dump() if env_config.tts_provider else None,
|
265 |
+
"stt_provider": env_config.stt_provider.model_dump() if env_config.stt_provider else None,
|
266 |
+
"providers": [p.model_dump() for p in env_config.providers],
|
267 |
+
"parameter_collection_config": env_config.parameter_collection_config.model_dump()
|
268 |
}
|
269 |
|
270 |
@router.put("/environment")
|
|
|
272 |
update: EnvironmentUpdate,
|
273 |
username: str = Depends(verify_token)
|
274 |
):
|
275 |
+
"""Update environment configuration with provider validation"""
|
276 |
log(f"📝 Updating environment config by {username}")
|
277 |
|
278 |
cfg = ConfigProvider.get()
|
|
|
282 |
if not llm_provider_def:
|
283 |
raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
|
284 |
|
|
|
285 |
if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
|
286 |
+
raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires API key")
|
287 |
|
288 |
if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
|
289 |
+
raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires endpoint")
|
290 |
+
|
291 |
# Validate TTS provider
|
292 |
tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
|
293 |
+
if not tts_provider_def:
|
294 |
raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
|
295 |
|
296 |
+
if tts_provider_def.requires_api_key and not update.tts_provider.api_key:
|
297 |
+
raise HTTPException(status_code=400, detail=f"{tts_provider_def.display_name} requires API key")
|
298 |
|
299 |
# Validate STT provider
|
300 |
stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
|
301 |
+
if not stt_provider_def:
|
302 |
raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
|
303 |
|
304 |
+
if stt_provider_def.requires_api_key and not update.stt_provider.api_key:
|
305 |
+
raise HTTPException(status_code=400, detail=f"{stt_provider_def.display_name} requires API key")
|
306 |
|
307 |
# Update via ConfigProvider
|
308 |
ConfigProvider.update_environment(update.model_dump(), username)
|
309 |
|
310 |
+
log(f"✅ Environment updated to LLM: {update.llm_provider.name}, TTS: {update.tts_provider.name}, STT: {update.stt_provider.name} by {username}")
|
311 |
return {"success": True}
|
312 |
|
313 |
# ===================== Project Endpoints =====================
|
314 |
+
@router.get("/projects/names")
|
315 |
+
def list_enabled_projects():
|
316 |
+
"""Get list of enabled project names for chat"""
|
317 |
+
cfg = ConfigProvider.get()
|
318 |
+
return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
|
319 |
+
|
320 |
@router.get("/projects")
|
321 |
async def list_projects(
|
322 |
include_deleted: bool = False,
|
|
|
332 |
|
333 |
return [p.model_dump() for p in projects]
|
334 |
|
335 |
+
@router.get("/projects/{project_id}")
|
336 |
+
async def get_project(
|
337 |
+
project_id: int,
|
338 |
+
username: str = Depends(verify_token)
|
339 |
+
):
|
340 |
+
"""Get single project by ID"""
|
341 |
+
project = ConfigProvider.get_project(project_id)
|
342 |
+
if not project or getattr(project, 'deleted', False):
|
343 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
344 |
+
|
345 |
+
return project.model_dump()
|
346 |
+
|
347 |
@router.post("/projects")
|
348 |
async def create_project(
|
349 |
project: ProjectCreate,
|
350 |
username: str = Depends(verify_token)
|
351 |
):
|
352 |
+
"""Create new project with initial version"""
|
353 |
+
# Validate supported locales
|
354 |
+
from locale_manager import LocaleManager
|
355 |
+
|
356 |
+
invalid_locales = LocaleManager.validate_project_languages(project.supported_locales)
|
357 |
+
if invalid_locales:
|
358 |
+
available_locales = LocaleManager.get_available_locales_with_names()
|
359 |
+
available_codes = [locale['code'] for locale in available_locales]
|
360 |
+
raise HTTPException(
|
361 |
+
status_code=400,
|
362 |
+
detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
|
363 |
+
)
|
364 |
+
|
365 |
+
# Check if default locale is in supported locales
|
366 |
+
if project.default_locale not in project.supported_locales:
|
367 |
+
raise HTTPException(
|
368 |
+
status_code=400,
|
369 |
+
detail="Default locale must be one of the supported locales"
|
370 |
+
)
|
371 |
+
|
372 |
+
# Create project via ConfigProvider
|
373 |
new_project = ConfigProvider.create_project(project.model_dump(), username)
|
374 |
|
375 |
log(f"✅ Project '{project.name}' created by {username}")
|
376 |
return new_project.model_dump()
|
377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
378 |
@router.put("/projects/{project_id}")
|
379 |
async def update_project(
|
380 |
project_id: int,
|
|
|
382 |
username: str = Depends(verify_token)
|
383 |
):
|
384 |
"""Update project"""
|
385 |
+
# Validate supported locales
|
386 |
+
from locale_manager import LocaleManager
|
387 |
+
|
388 |
+
invalid_locales = LocaleManager.validate_project_languages(update.supported_locales)
|
389 |
+
if invalid_locales:
|
390 |
+
available_locales = LocaleManager.get_available_locales_with_names()
|
391 |
+
available_codes = [locale['code'] for locale in available_locales]
|
392 |
+
raise HTTPException(
|
393 |
+
status_code=400,
|
394 |
+
detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
|
395 |
+
)
|
396 |
|
397 |
+
# Update via ConfigProvider
|
398 |
+
updated_project = ConfigProvider.update_project(project_id, update.model_dump(), username)
|
399 |
+
|
400 |
+
log(f"✅ Project '{updated_project.name}' updated by {username}")
|
401 |
return updated_project.model_dump()
|
402 |
|
403 |
@router.delete("/projects/{project_id}")
|
|
|
405 |
"""Delete project (soft delete)"""
|
406 |
ConfigProvider.delete_project(project_id, username)
|
407 |
|
408 |
+
log(f"✅ Project deleted by {username}")
|
409 |
return {"success": True}
|
410 |
|
411 |
@router.patch("/projects/{project_id}/toggle")
|
412 |
async def toggle_project(project_id: int, username: str = Depends(verify_token)):
|
413 |
"""Toggle project enabled status"""
|
414 |
+
enabled = ConfigProvider.toggle_project(project_id, username)
|
415 |
|
416 |
+
log(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
|
417 |
+
return {"enabled": enabled}
|
418 |
|
419 |
# ===================== Version Endpoints =====================
|
420 |
@router.get("/projects/{project_id}/versions")
|
|
|
424 |
username: str = Depends(verify_token)
|
425 |
):
|
426 |
"""List project versions"""
|
427 |
+
project = ConfigProvider.get_project(project_id)
|
|
|
|
|
428 |
if not project:
|
429 |
raise HTTPException(status_code=404, detail="Project not found")
|
430 |
|
|
|
456 |
username: str = Depends(verify_token)
|
457 |
):
|
458 |
"""Update version"""
|
459 |
+
updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(), username)
|
460 |
|
461 |
log(f"✅ Version {version_id} updated for project {project_id} by {username}")
|
462 |
return updated_version.model_dump()
|
|
|
472 |
|
473 |
log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
|
474 |
|
475 |
+
# Notify LLM provider if project is enabled and provider requires repo info
|
476 |
+
cfg = ConfigProvider.get()
|
477 |
+
llm_provider_def = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
|
478 |
+
|
479 |
+
if project.enabled and llm_provider_def and llm_provider_def.requires_repo_info:
|
480 |
+
try:
|
481 |
+
await notify_llm_startup(project, version)
|
482 |
+
except Exception as e:
|
483 |
+
log(f"⚠️ Failed to notify LLM provider: {e}")
|
484 |
+
# Don't fail the publish
|
485 |
|
486 |
return {"success": True}
|
487 |
|
|
|
497 |
log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
|
498 |
return {"success": True}
|
499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
500 |
# ===================== API Endpoints =====================
|
501 |
@router.get("/apis")
|
502 |
async def list_apis(
|
|
|
528 |
username: str = Depends(verify_token)
|
529 |
):
|
530 |
"""Update API"""
|
531 |
+
updated_api = ConfigProvider.update_api(api_name, update.model_dump(), username)
|
532 |
|
533 |
log(f"✅ API '{api_name}' updated by {username}")
|
534 |
return updated_api.model_dump()
|
|
|
541 |
log(f"✅ API '{api_name}' deleted by {username}")
|
542 |
return {"success": True}
|
543 |
|
544 |
+
@router.post("/validate/regex")
|
545 |
+
async def validate_regex(
|
546 |
request: dict = Body(...),
|
547 |
username: str = Depends(verify_token)
|
548 |
):
|
549 |
+
"""Validate regex pattern"""
|
550 |
+
pattern = request.get("pattern", "")
|
551 |
+
test_value = request.get("test_value", "")
|
|
|
|
|
|
|
|
|
|
|
552 |
|
553 |
try:
|
554 |
+
import re
|
555 |
+
compiled_regex = re.compile(pattern)
|
556 |
+
matches = bool(compiled_regex.match(test_value))
|
557 |
+
|
558 |
return {
|
559 |
+
"valid": True,
|
560 |
+
"matches": matches,
|
561 |
+
"pattern": pattern,
|
562 |
+
"test_value": test_value
|
|
|
563 |
}
|
564 |
except Exception as e:
|
|
|
565 |
return {
|
566 |
+
"valid": False,
|
567 |
+
"matches": False,
|
568 |
+
"error": str(e),
|
569 |
+
"pattern": pattern,
|
570 |
+
"test_value": test_value
|
571 |
}
|
572 |
|
573 |
+
# ===================== Test Endpoints =====================
|
574 |
+
@router.post("/test/run-all")
|
575 |
+
async def run_all_tests(
|
576 |
+
request: TestRequest,
|
|
|
577 |
username: str = Depends(verify_token)
|
578 |
):
|
579 |
+
"""Run all tests"""
|
580 |
+
log(f"🧪 Running {request.test_type} tests requested by {username}")
|
581 |
+
|
582 |
+
# TODO: Implement test runner
|
583 |
+
# For now, return mock results
|
584 |
return {
|
585 |
+
"test_run_id": "test_" + datetime.now().isoformat(),
|
586 |
+
"status": "running",
|
587 |
+
"total_tests": 60,
|
588 |
+
"completed": 0,
|
589 |
+
"passed": 0,
|
590 |
+
"failed": 0,
|
591 |
+
"message": "Test run started"
|
592 |
}
|
593 |
|
594 |
+
@router.get("/test/status/{test_run_id}")
|
595 |
+
async def get_test_status(
|
596 |
+
test_run_id: str,
|
597 |
+
username: str = Depends(verify_token)
|
598 |
+
):
|
599 |
+
"""Get test run status"""
|
600 |
+
# TODO: Implement test status tracking
|
601 |
return {
|
602 |
+
"test_run_id": test_run_id,
|
603 |
+
"status": "completed",
|
604 |
+
"total_tests": 60,
|
605 |
+
"completed": 60,
|
606 |
+
"passed": 57,
|
607 |
+
"failed": 3,
|
608 |
+
"duration": 340.5,
|
609 |
+
"details": []
|
610 |
}
|
611 |
|
612 |
+
# ===================== Activity Log =====================
|
613 |
+
@router.get("/activity-log")
|
614 |
+
async def get_activity_log(
|
615 |
+
limit: int = Query(100, ge=1, le=1000),
|
616 |
+
entity_type: Optional[str] = None,
|
617 |
+
username: str = Depends(verify_token)
|
618 |
+
):
|
619 |
+
"""Get activity log"""
|
620 |
+
cfg = ConfigProvider.get()
|
621 |
+
logs = cfg.activity_log
|
622 |
+
|
623 |
+
# Filter by entity type if specified
|
624 |
+
if entity_type:
|
625 |
+
logs = [l for l in logs if l.entity_type == entity_type]
|
626 |
+
|
627 |
+
# Return most recent entries
|
628 |
+
return logs[-limit:]
|
629 |
|
630 |
+
# ===================== Helper Functions =====================
|
631 |
+
async def notify_llm_startup(project, version):
|
632 |
+
"""Notify LLM provider about project startup"""
|
633 |
+
from llm_factory import LLMFactory
|
634 |
+
|
635 |
+
try:
|
636 |
+
llm_provider = LLMFactory.create_provider()
|
637 |
+
|
638 |
+
# Build project config for startup
|
639 |
+
project_config = {
|
640 |
+
"name": project.name,
|
641 |
+
"version_id": version.id,
|
642 |
+
"repo_id": version.llm.repo_id,
|
643 |
+
"generation_config": version.llm.generation_config,
|
644 |
+
"use_fine_tune": version.llm.use_fine_tune,
|
645 |
+
"fine_tune_zip": version.llm.fine_tune_zip
|
646 |
+
}
|
647 |
+
|
648 |
+
success = await llm_provider.startup(project_config)
|
649 |
+
if success:
|
650 |
+
log(f"✅ LLM provider notified for project '{project.name}'")
|
651 |
+
else:
|
652 |
+
log(f"⚠️ LLM provider notification failed for project '{project.name}'")
|
653 |
+
|
654 |
+
except Exception as e:
|
655 |
+
log(f"❌ Error notifying LLM provider: {e}")
|
656 |
+
raise
|
657 |
+
|
658 |
+
# ===================== Cleanup Task =====================
|
659 |
+
def cleanup_activity_log():
|
660 |
+
"""Cleanup old activity log entries"""
|
661 |
while True:
|
662 |
try:
|
663 |
+
cfg = ConfigProvider.get()
|
664 |
+
|
665 |
+
# Keep only last 30 days
|
666 |
+
cutoff = datetime.now() - timedelta(days=30)
|
667 |
+
cutoff_str = cutoff.isoformat() + "Z"
|
668 |
|
669 |
+
original_count = len(cfg.activity_log)
|
670 |
+
cfg.activity_log = [
|
671 |
+
log for log in cfg.activity_log
|
672 |
+
if log.timestamp >= cutoff_str
|
673 |
+
]
|
674 |
|
675 |
+
if len(cfg.activity_log) < original_count:
|
676 |
+
removed = original_count - len(cfg.activity_log)
|
677 |
+
log(f"🧹 Cleaned up {removed} old activity log entries")
|
678 |
+
cfg.save()
|
679 |
+
|
680 |
except Exception as e:
|
681 |
+
log(f"❌ Activity log cleanup error: {e}")
|
682 |
+
|
683 |
+
# Run every hour
|
684 |
+
time.sleep(3600)
|
685 |
|
686 |
def start_cleanup_task():
|
687 |
+
"""Start the cleanup task in background"""
|
688 |
+
thread = threading.Thread(target=cleanup_activity_log, daemon=True)
|
689 |
+
thread.start()
|
690 |
+
log("🧹 Activity log cleanup task started")
|
|
|
|
|
|
|
|
|
|
|
|
|
|