Spaces:
Building
Building
Update admin_routes.py
Browse files- admin_routes.py +235 -664
admin_routes.py
CHANGED
@@ -21,15 +21,14 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
21 |
from pydantic import BaseModel, Field
|
22 |
|
23 |
from utils import log
|
24 |
-
from config_provider import ConfigProvider
|
25 |
from encryption_utils import encrypt, decrypt
|
26 |
|
27 |
# ===================== JWT Config =====================
|
28 |
def get_jwt_config():
|
29 |
"""Get JWT configuration based on environment"""
|
30 |
-
|
31 |
-
|
32 |
-
if work_mode == "hfcloud":
|
33 |
# Cloud mode - use secrets from environment
|
34 |
jwt_secret = os.getenv("JWT_SECRET")
|
35 |
if not jwt_secret:
|
@@ -63,264 +62,122 @@ class LoginResponse(BaseModel):
|
|
63 |
class ChangePasswordRequest(BaseModel):
|
64 |
current_password: str
|
65 |
new_password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
66 |
|
67 |
class EnvironmentUpdate(BaseModel):
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
internal_prompt: Optional[str] = None
|
72 |
-
tts_engine: str = "no_tts"
|
73 |
-
tts_engine_api_key: Optional[str] = None
|
74 |
-
tts_settings: Optional[Dict[str, Any]] = None
|
75 |
-
stt_engine: str = "no_stt"
|
76 |
-
stt_engine_api_key: Optional[str] = None
|
77 |
-
stt_settings: Optional[Dict[str, Any]] = None
|
78 |
-
parameter_collection_config: Optional[Dict[str, Any]] = None
|
79 |
|
80 |
class ProjectCreate(BaseModel):
|
81 |
name: str
|
82 |
caption: Optional[str] = ""
|
83 |
icon: Optional[str] = "folder"
|
84 |
description: Optional[str] = ""
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
region: str = "tr-TR"
|
89 |
-
|
90 |
class ProjectUpdate(BaseModel):
|
91 |
-
caption: str
|
92 |
-
icon: Optional[str] =
|
93 |
-
description: Optional[str] =
|
94 |
-
|
95 |
-
|
96 |
-
timezone: str = "Europe/Istanbul"
|
97 |
-
region: str = "tr-TR"
|
98 |
-
last_update_date: str
|
99 |
-
|
100 |
-
class VersionCreate(BaseModel):
|
101 |
-
caption: str
|
102 |
-
source_version_id: int | None = None # None → boş template
|
103 |
|
104 |
-
class
|
105 |
-
name: str
|
106 |
caption: Optional[str] = ""
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
parameters: List[Dict[str, Any]] = []
|
111 |
-
action: str
|
112 |
-
fallback_timeout_prompt: Optional[str] = None
|
113 |
-
fallback_error_prompt: Optional[str] = None
|
114 |
|
115 |
class VersionUpdate(BaseModel):
|
116 |
-
caption: str
|
117 |
-
general_prompt: str
|
118 |
-
llm: Dict[str, Any]
|
119 |
-
intents: List[
|
120 |
-
last_update_date: str
|
121 |
|
122 |
class APICreate(BaseModel):
|
123 |
name: str
|
124 |
url: str
|
125 |
-
method: str = "
|
126 |
-
headers: Dict[str,
|
127 |
body_template: Dict[str, Any] = {}
|
128 |
timeout_seconds: int = 10
|
129 |
-
retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
|
130 |
-
proxy: Optional[str] = None
|
131 |
auth: Optional[Dict[str, Any]] = None
|
132 |
response_prompt: Optional[str] = None
|
133 |
-
response_mappings: List[Dict[str, Any]] = []
|
134 |
|
135 |
class APIUpdate(BaseModel):
|
136 |
-
url: str
|
137 |
-
method: str
|
138 |
-
headers: Dict[str,
|
139 |
-
body_template: Dict[str, Any]
|
140 |
-
timeout_seconds: int
|
141 |
-
|
142 |
-
|
143 |
-
auth: Optional[Dict[str, Any]]
|
144 |
-
response_prompt: Optional[str]
|
145 |
-
response_mappings: List[Dict[str, Any]] = []
|
146 |
-
last_update_date: str
|
147 |
-
|
148 |
-
class TestRequest(BaseModel):
|
149 |
-
test_type: str # "all", "ui", "backend", "integration", "spark"
|
150 |
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
|
|
|
|
|
|
|
|
|
|
156 |
|
157 |
-
|
158 |
-
protected_namespaces = () # Pydantic uyarısını susturmak için
|
159 |
|
160 |
-
# ===================== Helpers =====================
|
161 |
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
162 |
"""Verify JWT token and return username"""
|
163 |
jwt_config = get_jwt_config()
|
164 |
|
165 |
try:
|
166 |
payload = jwt.decode(
|
167 |
-
credentials.credentials,
|
168 |
-
jwt_config["secret"],
|
169 |
algorithms=[jwt_config["algorithm"]]
|
170 |
)
|
171 |
username = payload.get("sub")
|
172 |
-
if username
|
173 |
raise HTTPException(status_code=401, detail="Invalid token")
|
174 |
return username
|
175 |
except jwt.ExpiredSignatureError:
|
176 |
raise HTTPException(status_code=401, detail="Token expired")
|
177 |
-
except jwt.InvalidTokenError:
|
178 |
raise HTTPException(status_code=401, detail="Invalid token")
|
179 |
|
180 |
-
|
181 |
-
get_username = verify_token
|
182 |
-
|
183 |
-
def hash_password(password: str, salt: str = None) -> tuple[str, str]:
|
184 |
-
"""Hash password with bcrypt.
|
185 |
-
Returns (hashed_password, salt)"""
|
186 |
-
if salt is None:
|
187 |
-
salt = bcrypt.gensalt().decode('utf-8')
|
188 |
-
|
189 |
-
# Ensure salt is bytes
|
190 |
-
salt_bytes = salt.encode('utf-8') if isinstance(salt, str) else salt
|
191 |
-
|
192 |
-
# Hash the password
|
193 |
-
hashed = bcrypt.hashpw(password.encode('utf-8'), salt_bytes)
|
194 |
-
|
195 |
-
return hashed.decode('utf-8'), salt
|
196 |
-
|
197 |
-
def verify_password(password: str, hashed: str, salt: str = None) -> bool:
|
198 |
"""Verify password against hash"""
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
if not project_name:
|
217 |
-
raise HTTPException(status_code=400, detail="project_name is required")
|
218 |
-
|
219 |
-
cfg = ConfigProvider.get()
|
220 |
-
spark_endpoint = str(cfg.global_config.spark_endpoint).rstrip("/")
|
221 |
-
spark_token = _get_spark_token()
|
222 |
-
|
223 |
-
if not spark_endpoint:
|
224 |
-
raise HTTPException(status_code=400, detail="Spark endpoint not configured")
|
225 |
-
|
226 |
-
if not spark_token:
|
227 |
-
raise HTTPException(status_code=400, detail="Spark token not configured")
|
228 |
-
|
229 |
-
headers = {
|
230 |
-
"Authorization": f"Bearer {spark_token}",
|
231 |
-
"Content-Type": "application/json"
|
232 |
-
}
|
233 |
-
|
234 |
-
try:
|
235 |
-
async with httpx.AsyncClient(timeout=30) as client:
|
236 |
-
# Hepsi POST request olarak gönderiliyor
|
237 |
-
response = await client.post(
|
238 |
-
f"{spark_endpoint}/project/{action}",
|
239 |
-
json={"project_name": project_name},
|
240 |
-
headers=headers
|
241 |
-
)
|
242 |
-
|
243 |
-
response.raise_for_status()
|
244 |
-
return response.json()
|
245 |
-
|
246 |
-
except httpx.HTTPStatusError as e:
|
247 |
-
error_detail = e.response.json() if e.response.text else {"error": str(e)}
|
248 |
-
raise HTTPException(status_code=e.response.status_code, detail=error_detail)
|
249 |
-
except Exception as e:
|
250 |
-
log(f"❌ Spark {action} failed: {e}")
|
251 |
-
raise HTTPException(status_code=500, detail=str(e))
|
252 |
-
|
253 |
-
def _get_spark_token() -> Optional[str]:
|
254 |
-
"""Get Spark token based on work_mode"""
|
255 |
-
cfg = ConfigProvider.get()
|
256 |
-
work_mode = cfg.global_config.work_mode
|
257 |
-
|
258 |
-
if work_mode in ("hfcloud", "cloud"):
|
259 |
-
# Cloud mode - use HuggingFace Secrets
|
260 |
-
token = os.getenv("SPARK_TOKEN")
|
261 |
-
if not token:
|
262 |
-
log("❌ SPARK_TOKEN not found in HuggingFace Secrets!")
|
263 |
-
return token
|
264 |
-
else:
|
265 |
-
# On-premise mode - use .env file
|
266 |
-
from dotenv import load_dotenv
|
267 |
-
load_dotenv()
|
268 |
-
return os.getenv("SPARK_TOKEN")
|
269 |
-
|
270 |
-
async def notify_spark_manual(project: dict, version: dict, global_config: dict):
|
271 |
-
"""Manual Spark notification (similar to notify_spark but returns response)"""
|
272 |
-
import httpx
|
273 |
-
|
274 |
-
spark_endpoint = global_config.get("spark_endpoint", "").rstrip("/")
|
275 |
-
spark_token = _get_spark_token()
|
276 |
-
|
277 |
-
if not spark_endpoint:
|
278 |
-
raise ValueError("Spark endpoint not configured")
|
279 |
-
|
280 |
-
if not spark_token:
|
281 |
-
raise ValueError("Spark token not configured")
|
282 |
-
|
283 |
-
work_mode = global_config.get("work_mode", "hfcloud")
|
284 |
-
cloud_token = global_config.get("cloud_token", "")
|
285 |
-
|
286 |
-
# Decrypt token if needed
|
287 |
-
if cloud_token and cloud_token.startswith("enc:"):
|
288 |
-
cloud_token = decrypt(cloud_token)
|
289 |
-
|
290 |
-
payload = {
|
291 |
-
"work_mode": work_mode,
|
292 |
-
"cloud_token": cloud_token,
|
293 |
-
"project_name": project["name"],
|
294 |
-
"project_version": version["id"],
|
295 |
-
"repo_id": version["llm"]["repo_id"],
|
296 |
-
"generation_config": version["llm"]["generation_config"],
|
297 |
-
"use_fine_tune": version["llm"]["use_fine_tune"],
|
298 |
-
"fine_tune_zip": version["llm"]["fine_tune_zip"] if version["llm"]["use_fine_tune"] else None
|
299 |
-
}
|
300 |
-
|
301 |
-
headers = {
|
302 |
-
"Authorization": f"Bearer {spark_token}",
|
303 |
-
"Content-Type": "application/json"
|
304 |
-
}
|
305 |
-
|
306 |
-
log(f"🚀 Manually notifying Spark about {project['name']} v{version['id']}")
|
307 |
-
|
308 |
-
async with httpx.AsyncClient(timeout=30) as client:
|
309 |
-
response = await client.post(spark_endpoint + "/startup", json=payload, headers=headers)
|
310 |
-
response.raise_for_status()
|
311 |
-
result = response.json()
|
312 |
-
log(f"✅ Spark manual notification successful: {result.get('message', 'OK')}")
|
313 |
-
return result
|
314 |
-
|
315 |
-
# ===================== Auth Endpoints =====================
|
316 |
@router.post("/login", response_model=LoginResponse)
|
317 |
async def login(request: LoginRequest):
|
318 |
-
"""
|
319 |
cfg = ConfigProvider.get()
|
320 |
-
users = cfg.global_config.users
|
321 |
|
322 |
# Find user
|
323 |
-
user = next((u for u in users if u.username == request.username), None)
|
324 |
if not user:
|
325 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
326 |
|
@@ -328,15 +185,8 @@ async def login(request: LoginRequest):
|
|
328 |
if not verify_password(request.password, user.password_hash, user.salt):
|
329 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
330 |
|
331 |
-
#
|
332 |
-
|
333 |
-
|
334 |
-
payload = {
|
335 |
-
"sub": request.username,
|
336 |
-
"exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"])
|
337 |
-
}
|
338 |
-
|
339 |
-
token = jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
|
340 |
|
341 |
log(f"✅ User '{request.username}' logged in")
|
342 |
return LoginResponse(token=token, username=request.username)
|
@@ -348,10 +198,9 @@ async def change_password(
|
|
348 |
):
|
349 |
"""Change user password"""
|
350 |
cfg = ConfigProvider.get()
|
351 |
-
users = cfg.global_config.users
|
352 |
|
353 |
# Find user
|
354 |
-
user = next((u for u in users if u.username == username), None)
|
355 |
if not user:
|
356 |
raise HTTPException(status_code=404, detail="User not found")
|
357 |
|
@@ -362,8 +211,12 @@ async def change_password(
|
|
362 |
# Hash new password
|
363 |
new_hash, new_salt = hash_password(request.new_password)
|
364 |
|
365 |
-
# Update user
|
366 |
-
|
|
|
|
|
|
|
|
|
367 |
|
368 |
log(f"✅ Password changed for user '{username}'")
|
369 |
return {"success": True}
|
@@ -404,17 +257,10 @@ async def get_environment(username: str = Depends(verify_token)):
|
|
404 |
env_config = cfg.global_config
|
405 |
|
406 |
return {
|
407 |
-
"
|
408 |
-
"
|
409 |
-
"
|
410 |
-
"
|
411 |
-
"tts_engine": env_config.tts_engine,
|
412 |
-
"tts_engine_api_key": env_config.tts_engine_api_key or "",
|
413 |
-
"tts_settings": env_config.get_tts_settings(),
|
414 |
-
"stt_engine": env_config.stt_engine,
|
415 |
-
"stt_engine_api_key": env_config.stt_engine_api_key or "",
|
416 |
-
"stt_settings": env_config.get_stt_settings(),
|
417 |
-
"parameter_collection_config": env_config.parameter_collection_config.model_dump()
|
418 |
}
|
419 |
|
420 |
@router.put("/environment")
|
@@ -425,46 +271,43 @@ async def update_environment(
|
|
425 |
"""Update environment configuration"""
|
426 |
log(f"📝 Updating environment config by {username}")
|
427 |
|
428 |
-
|
429 |
-
if update.work_mode in ("gpt4o", "gpt4o-mini"):
|
430 |
-
if not update.cloud_token:
|
431 |
-
raise HTTPException(status_code=400, detail="OpenAI API key is required for GPT modes")
|
432 |
-
if not update.cloud_token.startswith("sk-") and not update.cloud_token.startswith("enc:"):
|
433 |
-
raise HTTPException(status_code=400, detail="Invalid OpenAI API key format")
|
434 |
-
elif update.work_mode in ("hfcloud", "cloud"):
|
435 |
-
if not update.cloud_token:
|
436 |
-
raise HTTPException(status_code=400, detail="Cloud token is required for cloud modes")
|
437 |
-
|
438 |
-
# TTS/STT validation
|
439 |
-
if update.tts_engine not in ("no_tts", "elevenlabs", "blaze"):
|
440 |
-
raise HTTPException(status_code=400, detail="Invalid TTS engine")
|
441 |
|
442 |
-
|
443 |
-
|
|
|
|
|
444 |
|
445 |
-
|
446 |
-
|
|
|
447 |
|
448 |
-
if
|
449 |
-
raise HTTPException(status_code=400, detail=f"{update.
|
|
|
|
|
|
|
|
|
|
|
450 |
|
451 |
-
|
452 |
-
|
453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
454 |
|
455 |
# Update via ConfigProvider
|
456 |
ConfigProvider.update_environment(update.model_dump(), username)
|
457 |
|
458 |
-
log(f"✅ Environment updated
|
459 |
return {"success": True}
|
460 |
|
461 |
# ===================== Project Endpoints =====================
|
462 |
-
@router.get("/projects/names")
|
463 |
-
def list_enabled_projects():
|
464 |
-
"""Get list of enabled project names for chat"""
|
465 |
-
cfg = ConfigProvider.get()
|
466 |
-
return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
|
467 |
-
|
468 |
@router.get("/projects")
|
469 |
async def list_projects(
|
470 |
include_deleted: bool = False,
|
@@ -480,49 +323,31 @@ async def list_projects(
|
|
480 |
|
481 |
return [p.model_dump() for p in projects]
|
482 |
|
483 |
-
@router.get("/projects/{project_id}")
|
484 |
-
async def get_project(
|
485 |
-
project_id: int,
|
486 |
-
username: str = Depends(verify_token)
|
487 |
-
):
|
488 |
-
"""Get single project by ID"""
|
489 |
-
project = ConfigProvider.get_project(project_id)
|
490 |
-
if not project or getattr(project, 'deleted', False):
|
491 |
-
raise HTTPException(status_code=404, detail="Project not found")
|
492 |
-
|
493 |
-
return project.model_dump()
|
494 |
-
|
495 |
@router.post("/projects")
|
496 |
async def create_project(
|
497 |
project: ProjectCreate,
|
498 |
username: str = Depends(verify_token)
|
499 |
):
|
500 |
-
"""Create new project
|
501 |
-
# Validate supported languages
|
502 |
-
from locale_manager import LocaleManager
|
503 |
-
|
504 |
-
invalid_languages = LocaleManager.validate_project_languages(project.supported_languages)
|
505 |
-
if invalid_languages:
|
506 |
-
available_locales = LocaleManager.get_available_locales_with_names()
|
507 |
-
available_codes = [locale['code'] for locale in available_locales]
|
508 |
-
raise HTTPException(
|
509 |
-
status_code=400,
|
510 |
-
detail=f"Unsupported languages: {', '.join(invalid_languages)}. Available languages: {', '.join(available_codes)}"
|
511 |
-
)
|
512 |
-
|
513 |
-
# Check if default language is in supported languages
|
514 |
-
if not project.supported_languages:
|
515 |
-
raise HTTPException(
|
516 |
-
status_code=400,
|
517 |
-
detail="At least one supported language must be selected"
|
518 |
-
)
|
519 |
-
|
520 |
-
# Create project via ConfigProvider
|
521 |
new_project = ConfigProvider.create_project(project.model_dump(), username)
|
522 |
|
523 |
log(f"✅ Project '{project.name}' created by {username}")
|
524 |
return new_project.model_dump()
|
525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
526 |
@router.put("/projects/{project_id}")
|
527 |
async def update_project(
|
528 |
project_id: int,
|
@@ -530,10 +355,9 @@ async def update_project(
|
|
530 |
username: str = Depends(verify_token)
|
531 |
):
|
532 |
"""Update project"""
|
533 |
-
|
534 |
-
updated_project = ConfigProvider.update_project(project_id, update.model_dump(), username)
|
535 |
|
536 |
-
log(f"✅ Project
|
537 |
return updated_project.model_dump()
|
538 |
|
539 |
@router.delete("/projects/{project_id}")
|
@@ -541,16 +365,16 @@ async def delete_project(project_id: int, username: str = Depends(verify_token))
|
|
541 |
"""Delete project (soft delete)"""
|
542 |
ConfigProvider.delete_project(project_id, username)
|
543 |
|
544 |
-
log(f"✅ Project deleted by {username}")
|
545 |
return {"success": True}
|
546 |
|
547 |
@router.patch("/projects/{project_id}/toggle")
|
548 |
async def toggle_project(project_id: int, username: str = Depends(verify_token)):
|
549 |
"""Toggle project enabled status"""
|
550 |
-
|
551 |
|
552 |
-
log(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
|
553 |
-
return {"enabled": enabled}
|
554 |
|
555 |
# ===================== Version Endpoints =====================
|
556 |
@router.get("/projects/{project_id}/versions")
|
@@ -560,7 +384,9 @@ async def list_versions(
|
|
560 |
username: str = Depends(verify_token)
|
561 |
):
|
562 |
"""List project versions"""
|
563 |
-
|
|
|
|
|
564 |
if not project:
|
565 |
raise HTTPException(status_code=404, detail="Project not found")
|
566 |
|
@@ -592,7 +418,7 @@ async def update_version(
|
|
592 |
username: str = Depends(verify_token)
|
593 |
):
|
594 |
"""Update version"""
|
595 |
-
updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(), username)
|
596 |
|
597 |
log(f"✅ Version {version_id} updated for project {project_id} by {username}")
|
598 |
return updated_version.model_dump()
|
@@ -608,18 +434,7 @@ async def publish_version(
|
|
608 |
|
609 |
log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
|
610 |
|
611 |
-
# Notify
|
612 |
-
if project.enabled:
|
613 |
-
try:
|
614 |
-
cfg = ConfigProvider.get()
|
615 |
-
await notify_spark_manual(
|
616 |
-
project.model_dump(),
|
617 |
-
version.model_dump(),
|
618 |
-
cfg.global_config.model_dump()
|
619 |
-
)
|
620 |
-
except Exception as e:
|
621 |
-
log(f"⚠️ Failed to notify Spark: {e}")
|
622 |
-
# Don't fail the publish
|
623 |
|
624 |
return {"success": True}
|
625 |
|
@@ -635,6 +450,43 @@ async def delete_version(
|
|
635 |
log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
|
636 |
return {"success": True}
|
637 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
638 |
@router.post("/validate/regex")
|
639 |
async def validate_regex(
|
640 |
request: dict = Body(...),
|
@@ -695,7 +547,7 @@ async def update_api(
|
|
695 |
username: str = Depends(verify_token)
|
696 |
):
|
697 |
"""Update API"""
|
698 |
-
updated_api = ConfigProvider.update_api(api_name, update.model_dump(), username)
|
699 |
|
700 |
log(f"✅ API '{api_name}' updated by {username}")
|
701 |
return updated_api.model_dump()
|
@@ -707,369 +559,88 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
|
|
707 |
|
708 |
log(f"✅ API '{api_name}' deleted by {username}")
|
709 |
return {"success": True}
|
710 |
-
|
711 |
-
# ===================== Spark Integration Endpoints =====================
|
712 |
-
@router.post("/spark/startup")
|
713 |
-
async def spark_startup(request: dict = Body(...), username: str = Depends(verify_token)):
|
714 |
-
"""Trigger Spark startup for a project"""
|
715 |
-
project_name = request.get("project_name")
|
716 |
-
if not project_name:
|
717 |
-
raise HTTPException(status_code=400, detail="project_name is required")
|
718 |
-
|
719 |
-
project = ConfigProvider.get_project_by_name(project_name)
|
720 |
-
if not project:
|
721 |
-
raise HTTPException(status_code=404, detail=f"Project not found: {project_name}")
|
722 |
-
|
723 |
-
# Find published version
|
724 |
-
version = next((v for v in project.versions if v.published), None)
|
725 |
-
if not version:
|
726 |
-
raise HTTPException(status_code=400, detail=f"No published version found for project: {project_name}")
|
727 |
-
|
728 |
-
# Notify Spark
|
729 |
-
try:
|
730 |
-
cfg = ConfigProvider.get()
|
731 |
-
result = await notify_spark_manual(
|
732 |
-
project.model_dump(),
|
733 |
-
version.model_dump(),
|
734 |
-
cfg.global_config.model_dump()
|
735 |
-
)
|
736 |
-
return {"message": result.get("message", "Spark startup initiated")}
|
737 |
-
except Exception as e:
|
738 |
-
log(f"❌ Spark startup failed: {e}")
|
739 |
-
raise HTTPException(status_code=500, detail=str(e))
|
740 |
|
741 |
-
@router.
|
742 |
-
async def
|
743 |
-
|
744 |
-
|
745 |
-
|
746 |
-
|
747 |
-
|
748 |
-
if not spark_endpoint:
|
749 |
-
raise HTTPException(status_code=400, detail="Spark endpoint not configured")
|
750 |
-
|
751 |
-
if not spark_token:
|
752 |
-
raise HTTPException(status_code=400, detail="Spark token not configured")
|
753 |
|
754 |
-
|
755 |
-
|
756 |
-
}
|
757 |
|
758 |
-
|
759 |
-
|
760 |
-
response = await client.get(spark_endpoint + "/project/list", headers=headers)
|
761 |
-
response.raise_for_status()
|
762 |
-
return response.json()
|
763 |
-
except Exception as e:
|
764 |
-
log(f"❌ Failed to get Spark projects: {e}")
|
765 |
-
raise HTTPException(status_code=500, detail=str(e))
|
766 |
-
|
767 |
-
@router.post("/spark/project/enable")
|
768 |
-
async def spark_enable_project(request: dict = Body(...), username: str = Depends(verify_token)):
|
769 |
-
"""Enable project in Spark"""
|
770 |
-
return await _spark_project_control("enable", request.get("project_name"), username)
|
771 |
-
|
772 |
-
@router.post("/spark/project/disable")
|
773 |
-
async def spark_disable_project(request: dict = Body(...), username: str = Depends(verify_token)):
|
774 |
-
"""Disable project in Spark"""
|
775 |
-
return await _spark_project_control("disable", request.get("project_name"), username)
|
776 |
-
|
777 |
-
@router.delete("/spark/project/{project_name}")
|
778 |
-
async def spark_delete_project(project_name: str, username: str = Depends(verify_token)):
|
779 |
-
"""Delete project from Spark"""
|
780 |
-
return await _spark_project_control("delete", project_name, username)
|
781 |
-
|
782 |
-
# ===================== Test Endpoints =====================
|
783 |
-
@router.post("/apis/test")
|
784 |
-
async def test_api(api_data: dict = Body(...), username: str = Depends(verify_token)):
|
785 |
-
"""Test API endpoint with auth support"""
|
786 |
-
import requests
|
787 |
-
import time
|
788 |
|
789 |
try:
|
790 |
-
|
791 |
-
test_request = api_data.pop("test_request", None)
|
792 |
-
|
793 |
-
# Parse the APICreate model
|
794 |
-
api = APICreate(**api_data)
|
795 |
-
|
796 |
-
# Prepare headers
|
797 |
-
headers = api.headers.copy()
|
798 |
-
|
799 |
-
# Handle authentication if enabled
|
800 |
-
auth_token = None
|
801 |
-
if api.auth and api.auth.get("enabled"):
|
802 |
-
auth_config = api.auth
|
803 |
-
try:
|
804 |
-
log(f"🔑 Fetching auth token for test...")
|
805 |
-
|
806 |
-
# Make auth request
|
807 |
-
auth_response = requests.post(
|
808 |
-
auth_config["token_endpoint"],
|
809 |
-
json=auth_config.get("token_request_body", {}),
|
810 |
-
timeout=10
|
811 |
-
)
|
812 |
-
auth_response.raise_for_status()
|
813 |
-
|
814 |
-
# Extract token from response
|
815 |
-
auth_json = auth_response.json()
|
816 |
-
token_path = auth_config.get("response_token_path", "token").split(".")
|
817 |
-
|
818 |
-
auth_token = auth_json
|
819 |
-
for path_part in token_path:
|
820 |
-
auth_token = auth_token.get(path_part)
|
821 |
-
if auth_token is None:
|
822 |
-
raise ValueError(f"Token not found at path: {auth_config.get('response_token_path')}")
|
823 |
-
|
824 |
-
# Add token to headers
|
825 |
-
headers["Authorization"] = f"Bearer {auth_token}"
|
826 |
-
log(f"✅ Auth token obtained: {auth_token[:20]}...")
|
827 |
-
|
828 |
-
except Exception as e:
|
829 |
-
log(f"❌ Auth failed during test: {e}")
|
830 |
-
return {
|
831 |
-
"success": False,
|
832 |
-
"error": f"Authentication failed: {str(e)}"
|
833 |
-
}
|
834 |
-
|
835 |
-
# Use test_request if provided, otherwise use body_template
|
836 |
-
request_body = test_request if test_request is not None else api.body_template
|
837 |
-
|
838 |
-
# Make the actual API request
|
839 |
-
start_time = time.time()
|
840 |
-
|
841 |
-
# Determine how to send the body based on method
|
842 |
-
if api.method in ["POST", "PUT", "PATCH"]:
|
843 |
-
response = requests.request(
|
844 |
-
method=api.method,
|
845 |
-
url=api.url,
|
846 |
-
headers=headers,
|
847 |
-
json=request_body,
|
848 |
-
timeout=api.timeout_seconds,
|
849 |
-
proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
|
850 |
-
)
|
851 |
-
elif api.method == "GET":
|
852 |
-
response = requests.request(
|
853 |
-
method=api.method,
|
854 |
-
url=api.url,
|
855 |
-
headers=headers,
|
856 |
-
params=request_body if isinstance(request_body, dict) else None,
|
857 |
-
timeout=api.timeout_seconds,
|
858 |
-
proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
|
859 |
-
)
|
860 |
-
else: # DELETE, HEAD, etc.
|
861 |
-
response = requests.request(
|
862 |
-
method=api.method,
|
863 |
-
url=api.url,
|
864 |
-
headers=headers,
|
865 |
-
timeout=api.timeout_seconds,
|
866 |
-
proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
|
867 |
-
)
|
868 |
-
|
869 |
-
response_time = int((time.time() - start_time) * 1000)
|
870 |
-
|
871 |
-
# Prepare response body
|
872 |
-
try:
|
873 |
-
response_body = response.json()
|
874 |
-
except:
|
875 |
-
response_body = response.text
|
876 |
-
|
877 |
-
# Check if request was successful (2xx status codes)
|
878 |
-
is_success = 200 <= response.status_code < 300
|
879 |
-
|
880 |
-
# Extract values if response mappings are defined
|
881 |
-
extracted_values = []
|
882 |
-
if api.response_mappings and isinstance(response_body, dict):
|
883 |
-
from jsonpath_ng import parse
|
884 |
-
for mapping in api.response_mappings:
|
885 |
-
try:
|
886 |
-
jsonpath_expr = parse(mapping['json_path'])
|
887 |
-
matches = jsonpath_expr.find(response_body)
|
888 |
-
value = matches[0].value if matches else None
|
889 |
-
extracted_values.append({
|
890 |
-
"variable_name": mapping['variable_name'],
|
891 |
-
"value": value,
|
892 |
-
"type": mapping['type'],
|
893 |
-
"caption": mapping.get('caption', '')
|
894 |
-
})
|
895 |
-
except Exception as e:
|
896 |
-
log(f"Failed to extract {mapping['variable_name']}: {e}")
|
897 |
-
extracted_values.append({
|
898 |
-
"variable_name": mapping['variable_name'],
|
899 |
-
"value": None,
|
900 |
-
"error": str(e),
|
901 |
-
"type": mapping['type'],
|
902 |
-
"caption": mapping.get('caption', '')
|
903 |
-
})
|
904 |
-
|
905 |
-
result = {
|
906 |
-
"success": is_success,
|
907 |
-
"status_code": response.status_code,
|
908 |
-
"response_time": response_time,
|
909 |
-
"response_body": response_body,
|
910 |
-
"response_headers": dict(response.headers),
|
911 |
-
"request_body": request_body,
|
912 |
-
"request_headers": headers
|
913 |
-
}
|
914 |
-
|
915 |
-
# Add extracted values if any
|
916 |
-
if extracted_values:
|
917 |
-
result["extracted_values"] = extracted_values
|
918 |
-
|
919 |
-
# Add error info for non-2xx responses
|
920 |
-
if not is_success:
|
921 |
-
result["error"] = f"HTTP {response.status_code}: {response.reason}"
|
922 |
-
|
923 |
-
log(f"📋 Test result: {response.status_code} in {response_time}ms")
|
924 |
-
return result
|
925 |
-
|
926 |
-
except requests.exceptions.Timeout:
|
927 |
-
return {
|
928 |
-
"success": False,
|
929 |
-
"error": f"Request timed out after {api.timeout_seconds} seconds"
|
930 |
-
}
|
931 |
-
except requests.exceptions.ConnectionError as e:
|
932 |
return {
|
933 |
-
"success":
|
934 |
-
"
|
|
|
|
|
|
|
935 |
}
|
936 |
except Exception as e:
|
937 |
-
log(f"❌
|
938 |
return {
|
939 |
"success": False,
|
940 |
"error": str(e)
|
941 |
}
|
942 |
|
943 |
-
|
944 |
-
|
945 |
-
|
|
|
|
|
946 |
username: str = Depends(verify_token)
|
947 |
):
|
948 |
-
"""
|
949 |
-
# TODO: Implement
|
950 |
return {
|
951 |
-
"
|
952 |
-
"total":
|
953 |
-
"
|
954 |
-
"
|
955 |
-
"details": []
|
956 |
}
|
957 |
|
958 |
-
# =====================
|
959 |
-
@router.
|
960 |
-
async def
|
961 |
-
|
962 |
-
|
963 |
-
|
964 |
-
|
965 |
-
|
966 |
-
|
967 |
-
log(f"✅ Project '{imported_project.name}' imported by {username}")
|
968 |
-
return imported_project.model_dump()
|
969 |
-
|
970 |
-
@router.get("/projects/{project_id}/export")
|
971 |
-
async def export_project(
|
972 |
-
project_id: int,
|
973 |
-
username: str = Depends(verify_token)
|
974 |
-
):
|
975 |
-
"""Export project as JSON"""
|
976 |
-
export_data = ConfigProvider.export_project(project_id, username)
|
977 |
-
|
978 |
-
log(f"✅ Project exported by {username}")
|
979 |
-
return export_data
|
980 |
-
|
981 |
-
# ===================== TTS Endpoints =====================
|
982 |
-
@router.post("/tts/generate")
|
983 |
-
async def generate_tts(
|
984 |
-
request: TTSRequest,
|
985 |
-
username: str = Depends(verify_token)
|
986 |
-
):
|
987 |
-
"""Generate TTS audio from text"""
|
988 |
-
try:
|
989 |
-
# ConfigProvider'dan güncel config'i al (reload yerine get kullan)
|
990 |
-
cfg = ConfigProvider.get()
|
991 |
-
|
992 |
-
tts_engine = cfg.global_config.tts_engine
|
993 |
-
log(f"🔧 TTS Engine: {tts_engine}")
|
994 |
-
|
995 |
-
if tts_engine == "no_tts":
|
996 |
-
raise HTTPException(status_code=400, detail="TTS is not configured")
|
997 |
-
|
998 |
-
# Get decrypted API key
|
999 |
-
api_key = cfg.global_config.get_tts_api_key()
|
1000 |
-
|
1001 |
-
if not api_key:
|
1002 |
-
log("❌ TTS API key not found in config")
|
1003 |
-
raise HTTPException(status_code=400, detail="TTS API key not configured")
|
1004 |
-
|
1005 |
-
# Import here to avoid circular dependency
|
1006 |
-
from tts_interface import create_tts_provider
|
1007 |
-
|
1008 |
-
tts_provider = create_tts_provider(tts_engine, api_key)
|
1009 |
-
if not tts_provider:
|
1010 |
-
raise HTTPException(status_code=500, detail="Failed to create TTS provider")
|
1011 |
-
|
1012 |
-
log(f"🎤 Generating TTS for {len(request.text)} characters using {tts_engine}")
|
1013 |
-
log(f"📝 Voice: {request.voice_id}, Model: {request.model_id}, Format: {request.output_format}")
|
1014 |
-
|
1015 |
-
# Generate audio
|
1016 |
-
audio_data = await tts_provider.synthesize(
|
1017 |
-
text=request.text,
|
1018 |
-
voice_id=request.voice_id,
|
1019 |
-
model_id=request.model_id,
|
1020 |
-
output_format=request.output_format
|
1021 |
-
)
|
1022 |
-
|
1023 |
-
# Return audio data
|
1024 |
-
from fastapi.responses import Response
|
1025 |
-
|
1026 |
-
content_type = "audio/mpeg" if request.output_format and request.output_format.startswith("mp3") else "audio/wav"
|
1027 |
-
|
1028 |
-
return Response(
|
1029 |
-
content=audio_data,
|
1030 |
-
media_type=content_type,
|
1031 |
-
headers={
|
1032 |
-
"Content-Disposition": f"attachment; filename=tts_output.{request.output_format.split('_')[0] if request.output_format else 'mp3'}"
|
1033 |
-
}
|
1034 |
-
)
|
1035 |
-
|
1036 |
-
except HTTPException:
|
1037 |
-
raise
|
1038 |
-
except Exception as e:
|
1039 |
-
log(f"❌ TTS generation error: {str(e)}")
|
1040 |
-
import traceback
|
1041 |
-
log(traceback.format_exc())
|
1042 |
-
raise HTTPException(status_code=500, detail=f"TTS generation failed: {str(e)}")
|
1043 |
-
|
1044 |
-
|
1045 |
-
# ===================== Activity Log Endpoints =====================
|
1046 |
-
@router.get("/activity-log")
|
1047 |
-
async def get_activity_log(
|
1048 |
-
limit: int = Query(100, ge=1, le=1000),
|
1049 |
-
username: str = Depends(verify_token)
|
1050 |
-
):
|
1051 |
-
"""Get activity log"""
|
1052 |
-
cfg = ConfigProvider.get()
|
1053 |
-
logs = cfg.activity_log
|
1054 |
-
|
1055 |
-
# Return latest entries
|
1056 |
-
return [log.model_dump() for log in logs[-limit:]]
|
1057 |
|
1058 |
# ===================== Cleanup Task =====================
|
1059 |
-
|
1060 |
-
|
|
|
|
|
1061 |
while True:
|
1062 |
try:
|
1063 |
-
|
1064 |
-
|
|
|
|
|
|
|
|
|
1065 |
except Exception as e:
|
1066 |
-
log(f"
|
1067 |
-
|
1068 |
-
# Run every hour
|
1069 |
-
time.sleep(3600)
|
1070 |
|
1071 |
def start_cleanup_task():
|
1072 |
-
"""Start cleanup task
|
1073 |
-
|
1074 |
-
|
1075 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
from pydantic import BaseModel, Field
|
22 |
|
23 |
from utils import log
|
24 |
+
from config_provider import ConfigProvider, ProviderSettings
|
25 |
from encryption_utils import encrypt, decrypt
|
26 |
|
27 |
# ===================== JWT Config =====================
|
28 |
def get_jwt_config():
|
29 |
"""Get JWT configuration based on environment"""
|
30 |
+
# Check if running in HuggingFace Space
|
31 |
+
if os.environ.get("SPACE_ID"):
|
|
|
32 |
# Cloud mode - use secrets from environment
|
33 |
jwt_secret = os.getenv("JWT_SECRET")
|
34 |
if not jwt_secret:
|
|
|
62 |
class ChangePasswordRequest(BaseModel):
|
63 |
current_password: str
|
64 |
new_password: str
|
65 |
+
|
66 |
+
class ProviderSettingsUpdate(BaseModel):
|
67 |
+
name: str
|
68 |
+
api_key: Optional[str] = None
|
69 |
+
endpoint: Optional[str] = None
|
70 |
+
settings: Dict[str, Any] = Field(default_factory=dict)
|
71 |
|
72 |
class EnvironmentUpdate(BaseModel):
|
73 |
+
llm_provider: ProviderSettingsUpdate
|
74 |
+
tts_provider: ProviderSettingsUpdate
|
75 |
+
stt_provider: ProviderSettingsUpdate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
class ProjectCreate(BaseModel):
|
78 |
name: str
|
79 |
caption: Optional[str] = ""
|
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: Optional[str] = None
|
87 |
+
icon: Optional[str] = None
|
88 |
+
description: Optional[str] = None
|
89 |
+
default_locale: Optional[str] = None
|
90 |
+
supported_locales: Optional[List[str]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
|
92 |
+
class VersionCreate(BaseModel):
|
|
|
93 |
caption: Optional[str] = ""
|
94 |
+
general_prompt: str
|
95 |
+
llm: Optional[Dict[str, Any]] = None
|
96 |
+
intents: List[Dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
97 |
|
98 |
class VersionUpdate(BaseModel):
|
99 |
+
caption: Optional[str] = None
|
100 |
+
general_prompt: Optional[str] = None
|
101 |
+
llm: Optional[Dict[str, Any]] = None
|
102 |
+
intents: Optional[List[Dict[str, Any]]] = None
|
|
|
103 |
|
104 |
class APICreate(BaseModel):
|
105 |
name: str
|
106 |
url: str
|
107 |
+
method: str = "GET"
|
108 |
+
headers: Dict[str, Any] = {}
|
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: Optional[str] = None
|
116 |
+
method: Optional[str] = None
|
117 |
+
headers: Optional[Dict[str, Any]] = None
|
118 |
+
body_template: Optional[Dict[str, Any]] = None
|
119 |
+
timeout_seconds: Optional[int] = None
|
120 |
+
auth: Optional[Dict[str, Any]] = None
|
121 |
+
response_prompt: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
122 |
|
123 |
+
# ===================== Auth Helpers =====================
|
124 |
+
def create_token(username: str) -> str:
|
125 |
+
"""Create JWT token"""
|
126 |
+
jwt_config = get_jwt_config()
|
127 |
+
|
128 |
+
payload = {
|
129 |
+
"sub": username,
|
130 |
+
"exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"]),
|
131 |
+
"iat": datetime.now(timezone.utc)
|
132 |
+
}
|
133 |
|
134 |
+
return jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
|
|
|
135 |
|
|
|
136 |
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
137 |
"""Verify JWT token and return username"""
|
138 |
jwt_config = get_jwt_config()
|
139 |
|
140 |
try:
|
141 |
payload = jwt.decode(
|
142 |
+
credentials.credentials,
|
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 |
+
def verify_password(plain_password: str, hashed_password: str, salt: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
180 |
+
user = next((u for u in cfg.global_config.users if u.username == request.username), None)
|
181 |
if not user:
|
182 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
183 |
|
|
|
185 |
if not verify_password(request.password, user.password_hash, user.salt):
|
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)
|
|
|
198 |
):
|
199 |
"""Change user password"""
|
200 |
cfg = ConfigProvider.get()
|
|
|
201 |
|
202 |
# Find user
|
203 |
+
user = next((u for u in cfg.global_config.users if u.username == username), None)
|
204 |
if not user:
|
205 |
raise HTTPException(status_code=404, detail="User not found")
|
206 |
|
|
|
211 |
# Hash new password
|
212 |
new_hash, new_salt = hash_password(request.new_password)
|
213 |
|
214 |
+
# Update user
|
215 |
+
user.password_hash = new_hash
|
216 |
+
user.salt = new_salt
|
217 |
+
|
218 |
+
# Save config
|
219 |
+
ConfigProvider._save()
|
220 |
|
221 |
log(f"✅ Password changed for user '{username}'")
|
222 |
return {"success": True}
|
|
|
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")
|
|
|
271 |
"""Update environment configuration"""
|
272 |
log(f"📝 Updating environment config by {username}")
|
273 |
|
274 |
+
cfg = ConfigProvider.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
275 |
|
276 |
+
# Validate LLM provider
|
277 |
+
llm_provider_def = cfg.global_config.get_provider_config("llm", update.llm_provider.name)
|
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 is required for {update.llm_provider.name}")
|
284 |
|
285 |
+
if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
|
286 |
+
raise HTTPException(status_code=400, detail=f"Endpoint is required for {update.llm_provider.name}")
|
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 and update.tts_provider.name != "no_tts":
|
291 |
+
raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
|
292 |
|
293 |
+
if tts_provider_def and tts_provider_def.requires_api_key and not update.tts_provider.api_key:
|
294 |
+
raise HTTPException(status_code=400, detail=f"API key is required for {update.tts_provider.name}")
|
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 and update.stt_provider.name != "no_stt":
|
299 |
+
raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
|
300 |
+
|
301 |
+
if stt_provider_def and stt_provider_def.requires_api_key and not update.stt_provider.api_key:
|
302 |
+
raise HTTPException(status_code=400, detail=f"API key is required for {update.stt_provider.name}")
|
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 |
|
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 |
username: str = Depends(verify_token)
|
356 |
):
|
357 |
"""Update project"""
|
358 |
+
updated_project = ConfigProvider.update_project(project_id, update.model_dump(exclude_unset=True), username)
|
|
|
359 |
|
360 |
+
log(f"✅ Project {project_id} updated by {username}")
|
361 |
return updated_project.model_dump()
|
362 |
|
363 |
@router.delete("/projects/{project_id}")
|
|
|
365 |
"""Delete project (soft delete)"""
|
366 |
ConfigProvider.delete_project(project_id, username)
|
367 |
|
368 |
+
log(f"✅ Project {project_id} deleted by {username}")
|
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 |
+
project = ConfigProvider.toggle_project(project_id, username)
|
375 |
|
376 |
+
log(f"✅ Project {project_id} {'enabled' if project.enabled else 'disabled'} by {username}")
|
377 |
+
return {"success": True, "enabled": project.enabled}
|
378 |
|
379 |
# ===================== Version Endpoints =====================
|
380 |
@router.get("/projects/{project_id}/versions")
|
|
|
384 |
username: str = Depends(verify_token)
|
385 |
):
|
386 |
"""List project versions"""
|
387 |
+
cfg = ConfigProvider.get()
|
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 |
username: str = Depends(verify_token)
|
419 |
):
|
420 |
"""Update version"""
|
421 |
+
updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(exclude_unset=True), username)
|
422 |
|
423 |
log(f"✅ Version {version_id} updated for project {project_id} by {username}")
|
424 |
return updated_version.model_dump()
|
|
|
434 |
|
435 |
log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
|
436 |
|
437 |
+
# TODO: Notify LLM provider if needed (only for Spark variants)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
438 |
|
439 |
return {"success": True}
|
440 |
|
|
|
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(...),
|
|
|
547 |
username: str = Depends(verify_token)
|
548 |
):
|
549 |
"""Update API"""
|
550 |
+
updated_api = ConfigProvider.update_api(api_name, update.model_dump(exclude_unset=True), username)
|
551 |
|
552 |
log(f"✅ API '{api_name}' updated by {username}")
|
553 |
return updated_api.model_dump()
|
|
|
559 |
|
560 |
log(f"✅ API '{api_name}' deleted by {username}")
|
561 |
return {"success": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
562 |
|
563 |
+
@router.post("/apis/test")
|
564 |
+
async def test_api(
|
565 |
+
request: dict = Body(...),
|
566 |
+
username: str = Depends(verify_token)
|
567 |
+
):
|
568 |
+
"""Test API endpoint"""
|
569 |
+
from api_executor import test_api_call
|
|
|
|
|
|
|
|
|
|
|
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 |
+
result = await test_api_call(api_config, test_params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
579 |
return {
|
580 |
+
"success": True,
|
581 |
+
"status_code": result.get("status_code"),
|
582 |
+
"response": result.get("response"),
|
583 |
+
"headers": result.get("headers"),
|
584 |
+
"duration_ms": result.get("duration_ms")
|
585 |
}
|
586 |
except Exception as e:
|
587 |
+
log(f"❌ API test failed: {e}")
|
588 |
return {
|
589 |
"success": False,
|
590 |
"error": str(e)
|
591 |
}
|
592 |
|
593 |
+
# ===================== Activity Log =====================
|
594 |
+
@router.get("/activity-log")
|
595 |
+
async def get_activity_log(
|
596 |
+
limit: int = Query(50, ge=1, le=500),
|
597 |
+
offset: int = Query(0, ge=0),
|
598 |
username: str = Depends(verify_token)
|
599 |
):
|
600 |
+
"""Get activity log entries"""
|
601 |
+
# TODO: Implement when activity logging is added
|
602 |
return {
|
603 |
+
"entries": [],
|
604 |
+
"total": 0,
|
605 |
+
"limit": limit,
|
606 |
+
"offset": offset
|
|
|
607 |
}
|
608 |
|
609 |
+
# ===================== Health Check =====================
|
610 |
+
@router.get("/health")
|
611 |
+
async def health_check():
|
612 |
+
"""Health check endpoint"""
|
613 |
+
return {
|
614 |
+
"status": "ok",
|
615 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
616 |
+
"version": "1.0.0"
|
617 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
618 |
|
619 |
# ===================== Cleanup Task =====================
|
620 |
+
_cleanup_thread = None
|
621 |
+
|
622 |
+
def cleanup_activity_logs():
|
623 |
+
"""Cleanup old activity logs periodically"""
|
624 |
while True:
|
625 |
try:
|
626 |
+
# Sleep for 24 hours
|
627 |
+
time.sleep(86400)
|
628 |
+
|
629 |
+
# TODO: Implement cleanup logic when activity logging is added
|
630 |
+
log("🧹 Running activity log cleanup...")
|
631 |
+
|
632 |
except Exception as e:
|
633 |
+
log(f"❌ Cleanup error: {e}")
|
|
|
|
|
|
|
634 |
|
635 |
def start_cleanup_task():
|
636 |
+
"""Start background cleanup task"""
|
637 |
+
global _cleanup_thread
|
638 |
+
if _cleanup_thread is None:
|
639 |
+
_cleanup_thread = threading.Thread(target=cleanup_activity_logs, daemon=True)
|
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"
|