snowglobe 0.4.0__py3-none-any.whl → 0.4.2__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.
- snowglobe/client/src/app.py +348 -152
- snowglobe/client/src/cli.py +114 -41
- snowglobe/client/src/cli_utils.py +155 -9
- snowglobe/client/src/config.py +36 -4
- snowglobe/client/src/utils.py +3 -3
- {snowglobe-0.4.0.dist-info → snowglobe-0.4.2.dist-info}/METADATA +3 -10
- snowglobe-0.4.2.dist-info/RECORD +15 -0
- snowglobe-0.4.0.dist-info/RECORD +0 -15
- {snowglobe-0.4.0.dist-info → snowglobe-0.4.2.dist-info}/WHEEL +0 -0
- {snowglobe-0.4.0.dist-info → snowglobe-0.4.2.dist-info}/entry_points.txt +0 -0
- {snowglobe-0.4.0.dist-info → snowglobe-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {snowglobe-0.4.0.dist-info → snowglobe-0.4.2.dist-info}/top_level.txt +0 -0
snowglobe/client/src/app.py
CHANGED
@@ -20,8 +20,8 @@ from apscheduler import AsyncScheduler
|
|
20
20
|
from apscheduler.triggers.interval import IntervalTrigger
|
21
21
|
from fastapi import FastAPI, HTTPException, Request
|
22
22
|
|
23
|
-
from .cli_utils import info
|
24
|
-
from .config import config
|
23
|
+
from .cli_utils import info, shutdown_manager
|
24
|
+
from .config import config, get_api_key_or_raise
|
25
25
|
from .models import CompletionFunctionOutputs, CompletionRequest, RiskEvaluationRequest
|
26
26
|
from .stats import initialize_stats, track_batch_completion
|
27
27
|
from .utils import fetch_experiments, fetch_messages
|
@@ -190,7 +190,7 @@ async def process_application_heartbeat(app_id):
|
|
190
190
|
connection_test_response = await client.post(
|
191
191
|
connection_test_url,
|
192
192
|
json=connection_test_payload,
|
193
|
-
headers={"x-api-key":
|
193
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
194
194
|
)
|
195
195
|
|
196
196
|
if not connection_test_response.is_success:
|
@@ -240,7 +240,7 @@ async def process_risk_evaluation(test, risk_name):
|
|
240
240
|
"risk_type": risk_name,
|
241
241
|
"risk_triggered": risk_evaluation.triggered,
|
242
242
|
},
|
243
|
-
headers={"x-api-key":
|
243
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
244
244
|
)
|
245
245
|
LOGGER.debug(f"Time taken: {time.time() - start} seconds")
|
246
246
|
if not risk_evaluation_response.is_success:
|
@@ -271,7 +271,7 @@ async def process_test(test, completion_fn, app_id):
|
|
271
271
|
"response": completionOutput.response,
|
272
272
|
"persona": test["persona"],
|
273
273
|
},
|
274
|
-
headers={"x-api-key":
|
274
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
275
275
|
)
|
276
276
|
LOGGER.debug(f"Time taken: {time.time() - start} seconds")
|
277
277
|
# remove test['id'] from queued_tests
|
@@ -282,186 +282,382 @@ async def process_test(test, completion_fn, app_id):
|
|
282
282
|
|
283
283
|
# The task to run
|
284
284
|
async def poll_for_completions():
|
285
|
+
# Exit early if shutdown requested
|
286
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
287
|
+
return
|
288
|
+
|
289
|
+
job_id = "poll_for_completions"
|
290
|
+
|
285
291
|
if len(apps) == 0:
|
286
292
|
LOGGER.warning("No applications found. Skipping completions check.")
|
287
293
|
return
|
288
|
-
experiments = await fetch_experiments()
|
289
294
|
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
295
|
+
# Register this job as active
|
296
|
+
shutdown_manager.register_active_job(job_id)
|
297
|
+
|
298
|
+
try:
|
299
|
+
experiments = await fetch_experiments()
|
300
|
+
except Exception as e:
|
301
|
+
# Handle shutdown-related connection errors gracefully
|
302
|
+
if shutdown_manager.is_shutdown_requested():
|
303
|
+
LOGGER.debug(f"Connection error during shutdown (expected): {e}")
|
304
|
+
return
|
305
|
+
else:
|
306
|
+
LOGGER.error(f"Error fetching experiments: {e}")
|
307
|
+
raise
|
308
|
+
finally:
|
309
|
+
shutdown_manager.unregister_active_job(job_id)
|
310
|
+
|
311
|
+
try:
|
312
|
+
async with httpx.AsyncClient() as client:
|
313
|
+
LOGGER.info(f"Polling {len(experiments)} experiments for completions...")
|
314
|
+
for experiment in experiments:
|
299
315
|
LOGGER.debug(
|
300
|
-
f"
|
301
|
-
)
|
302
|
-
continue
|
303
|
-
experiment_id = experiment["id"]
|
304
|
-
experiment_request = await client.get(
|
305
|
-
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}?appId={app_id}",
|
306
|
-
headers={"x-api-key": config.API_KEY},
|
307
|
-
)
|
308
|
-
if not experiment_request.is_success:
|
309
|
-
LOGGER.error(
|
310
|
-
f"Error fetching experiment {experiment_id}: {experiment_request.text}"
|
316
|
+
f"Checking experiment: id={experiment.get('id')}, name={experiment.get('name', 'unknown')}"
|
311
317
|
)
|
312
|
-
continue
|
313
|
-
experiment = experiment_request.json()
|
314
318
|
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
)
|
319
|
+
for experiment in experiments:
|
320
|
+
# Check for shutdown between each experiment
|
321
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
322
|
+
LOGGER.debug("Shutdown requested, stopping completions polling")
|
323
|
+
return
|
321
324
|
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
+
app_id = experiment.get("app_id")
|
326
|
+
if app_id not in apps:
|
327
|
+
LOGGER.debug(
|
328
|
+
f"Skipping experiment as we do not have a completion function for app_id: {app_id}"
|
329
|
+
)
|
330
|
+
continue
|
331
|
+
experiment_id = experiment["id"]
|
325
332
|
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
if not test["response"] and test["id"] not in queued_tests:
|
331
|
-
completion_request = await httpx.AsyncClient().post(
|
332
|
-
f"{config.SNOWGLOBE_CLIENT_URL}/completion",
|
333
|
-
json={"test": test, "app_id": app_id},
|
334
|
-
timeout=30,
|
333
|
+
try:
|
334
|
+
experiment_request = await client.get(
|
335
|
+
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}?appId={app_id}",
|
336
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
335
337
|
)
|
336
|
-
|
337
|
-
if (
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
LOGGER.
|
342
|
-
f"
|
343
|
-
)
|
344
|
-
raise ValueError(
|
345
|
-
status_code=429,
|
346
|
-
detail=f"Rate limit exceeded for test {test['id']}",
|
338
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
339
|
+
if shutdown_manager.is_shutdown_requested():
|
340
|
+
LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
|
341
|
+
return
|
342
|
+
else:
|
343
|
+
LOGGER.error(
|
344
|
+
f"Connection error fetching experiment {experiment_id}: {e}"
|
347
345
|
)
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
LOGGER.info(
|
354
|
-
f"Processed {completion_count} completions for experiment {experiment_name} ({experiment_id})"
|
346
|
+
continue
|
347
|
+
|
348
|
+
if not experiment_request.is_success:
|
349
|
+
LOGGER.error(
|
350
|
+
f"Error fetching experiment {experiment_id}: {experiment_request.text}"
|
355
351
|
)
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
352
|
+
continue
|
353
|
+
experiment = experiment_request.json()
|
354
|
+
|
355
|
+
limit = 10
|
356
|
+
LOGGER.debug(f"Checking for tests for experiment {experiment_id}")
|
357
|
+
|
358
|
+
try:
|
359
|
+
tests_response = await client.get(
|
360
|
+
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}/tests?appId={app_id}&include-risk-evaluations=false&limit={limit}&unprocessed-only=true",
|
361
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
360
362
|
)
|
363
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
364
|
+
if shutdown_manager.is_shutdown_requested():
|
365
|
+
LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
|
366
|
+
return
|
367
|
+
else:
|
368
|
+
LOGGER.error(f"Connection error fetching tests: {e}")
|
369
|
+
continue
|
370
|
+
|
371
|
+
if not tests_response.is_success:
|
372
|
+
LOGGER.error(f"Error fetching tests: {tests_response.text}")
|
373
|
+
continue
|
361
374
|
|
362
|
-
|
363
|
-
|
375
|
+
tests = tests_response.json()
|
376
|
+
completion_count = 0
|
377
|
+
for test in tests:
|
378
|
+
# Check for shutdown before processing each test
|
379
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
380
|
+
LOGGER.debug("Shutdown requested, stopping test processing")
|
381
|
+
return
|
382
|
+
|
383
|
+
LOGGER.debug(
|
384
|
+
f"Found test {test['id']} for experiment {experiment_id}"
|
385
|
+
)
|
386
|
+
if not test["response"] and test["id"] not in queued_tests:
|
387
|
+
try:
|
388
|
+
completion_request = await httpx.AsyncClient().post(
|
389
|
+
f"{config.SNOWGLOBE_CLIENT_URL}/completion",
|
390
|
+
json={"test": test, "app_id": app_id},
|
391
|
+
timeout=30,
|
392
|
+
)
|
393
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
394
|
+
if shutdown_manager.is_shutdown_requested():
|
395
|
+
LOGGER.debug(
|
396
|
+
f"HTTP error during shutdown (expected): {e}"
|
397
|
+
)
|
398
|
+
return
|
399
|
+
else:
|
400
|
+
LOGGER.error(
|
401
|
+
f"Connection error posting completion: {e}"
|
402
|
+
)
|
403
|
+
continue
|
404
|
+
|
405
|
+
# if 429 raise and exception and stop this batch
|
406
|
+
if (
|
407
|
+
not completion_request.is_success
|
408
|
+
and completion_request.status_code == 429
|
409
|
+
):
|
410
|
+
LOGGER.warning(
|
411
|
+
f"Completion Rate limit exceeded for test {test['id']}: {completion_request.text}"
|
412
|
+
)
|
413
|
+
raise ValueError(
|
414
|
+
status_code=429,
|
415
|
+
detail=f"Rate limit exceeded for test {test['id']}",
|
416
|
+
)
|
417
|
+
completion_count += 1
|
418
|
+
queued_tests[test["id"]] = True
|
419
|
+
|
420
|
+
if completion_count > 0:
|
421
|
+
experiment_name = experiment.get("name", "unknown")
|
422
|
+
if LOGGER.level <= logging.INFO: # Verbose mode
|
423
|
+
LOGGER.info(
|
424
|
+
f"Processed {completion_count} completions for experiment {experiment_name} ({experiment_id})"
|
425
|
+
)
|
426
|
+
else: # Clean UI mode
|
427
|
+
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
428
|
+
info(
|
429
|
+
f"[{timestamp}] ✓ Batch complete: {completion_count} responses sent ({experiment_name})"
|
430
|
+
)
|
431
|
+
|
432
|
+
# Track batch completion
|
433
|
+
track_batch_completion(experiment_name, completion_count)
|
434
|
+
|
435
|
+
except Exception as e:
|
436
|
+
# Handle any other errors
|
437
|
+
if shutdown_manager.is_shutdown_requested():
|
438
|
+
LOGGER.debug(f"Error during shutdown (expected): {e}")
|
439
|
+
else:
|
440
|
+
LOGGER.error(f"Unexpected error in poll_for_completions: {e}")
|
441
|
+
raise
|
364
442
|
|
365
443
|
|
366
444
|
async def process_application_heartbeats():
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
connection_test_request = await httpx.AsyncClient().post(
|
371
|
-
f"{config.SNOWGLOBE_CLIENT_URL}/heartbeat",
|
372
|
-
json={"app_id": app_id},
|
373
|
-
timeout=30,
|
374
|
-
)
|
375
|
-
if not connection_test_request.is_success:
|
376
|
-
LOGGER.error(
|
377
|
-
f"Error sending heartbeat for application {app_id}: {connection_test_request.text}"
|
378
|
-
)
|
379
|
-
continue
|
380
|
-
connection_test_count += 1
|
445
|
+
# Exit early if shutdown requested
|
446
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
447
|
+
return
|
381
448
|
|
382
|
-
|
449
|
+
job_id = "process_application_heartbeats"
|
450
|
+
shutdown_manager.register_active_job(job_id)
|
383
451
|
|
452
|
+
try:
|
453
|
+
connection_test_count = 0
|
454
|
+
LOGGER.info("Processing application heartbeats...")
|
455
|
+
for app_id, app_info in apps.items():
|
456
|
+
# Check for shutdown between each heartbeat
|
457
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
458
|
+
LOGGER.debug("Shutdown requested, stopping heartbeats")
|
459
|
+
return
|
384
460
|
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
461
|
+
try:
|
462
|
+
connection_test_request = await httpx.AsyncClient().post(
|
463
|
+
f"{config.SNOWGLOBE_CLIENT_URL}/heartbeat",
|
464
|
+
json={"app_id": app_id},
|
465
|
+
timeout=30,
|
466
|
+
)
|
467
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
468
|
+
if shutdown_manager.is_shutdown_requested():
|
469
|
+
LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
|
470
|
+
return
|
471
|
+
else:
|
472
|
+
LOGGER.error(
|
473
|
+
f"Connection error sending heartbeat for {app_id}: {e}"
|
474
|
+
)
|
475
|
+
continue
|
476
|
+
|
477
|
+
if not connection_test_request.is_success:
|
398
478
|
LOGGER.error(
|
399
|
-
f"Error
|
479
|
+
f"Error sending heartbeat for application {app_id}: {connection_test_request.text}"
|
400
480
|
)
|
401
481
|
continue
|
402
|
-
|
403
|
-
|
404
|
-
|
482
|
+
connection_test_count += 1
|
483
|
+
|
484
|
+
LOGGER.info(f"Processed {connection_test_count} heartbeats for applications.")
|
485
|
+
finally:
|
486
|
+
shutdown_manager.unregister_active_job(job_id)
|
487
|
+
|
488
|
+
|
489
|
+
async def poll_for_risk_evaluations():
|
490
|
+
"""Poll for risk evaluations and process them."""
|
491
|
+
# Exit early if shutdown requested
|
492
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
493
|
+
return
|
494
|
+
|
495
|
+
job_id = "poll_for_risk_evaluations"
|
496
|
+
shutdown_manager.register_active_job(job_id)
|
497
|
+
|
498
|
+
try:
|
499
|
+
experiments = await fetch_experiments()
|
500
|
+
except Exception as e:
|
501
|
+
# Handle shutdown-related connection errors gracefully
|
502
|
+
if shutdown_manager.is_shutdown_requested():
|
503
|
+
LOGGER.debug(f"Connection error during shutdown (expected): {e}")
|
504
|
+
return
|
505
|
+
else:
|
506
|
+
LOGGER.error(f"Error fetching experiments: {e}")
|
507
|
+
raise
|
508
|
+
finally:
|
509
|
+
shutdown_manager.unregister_active_job(job_id)
|
510
|
+
|
511
|
+
try:
|
512
|
+
LOGGER.info("Checking for pending risk evaluations...")
|
513
|
+
LOGGER.debug(
|
514
|
+
f"Found {len(experiments)} experiments with validation in progress"
|
515
|
+
)
|
516
|
+
|
517
|
+
async with httpx.AsyncClient() as client:
|
518
|
+
for experiment in experiments:
|
519
|
+
# Check for shutdown between experiments
|
520
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
521
|
+
LOGGER.debug("Shutdown requested, stopping risk evaluations")
|
522
|
+
return
|
523
|
+
|
405
524
|
try:
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
):
|
412
|
-
LOGGER.debug(
|
413
|
-
|
525
|
+
experiment_request = await client.get(
|
526
|
+
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}",
|
527
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
528
|
+
)
|
529
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
530
|
+
if shutdown_manager.is_shutdown_requested():
|
531
|
+
LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
|
532
|
+
return
|
533
|
+
else:
|
534
|
+
LOGGER.error(
|
535
|
+
f"Connection error fetching experiment {experiment['id']}: {e}"
|
414
536
|
)
|
415
537
|
continue
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}/tests?unevaluated-risk={quote_plus(risk_name)}&include-risk-evaluations=true",
|
421
|
-
headers={"x-api-key": config.API_KEY},
|
538
|
+
|
539
|
+
if not experiment_request.is_success:
|
540
|
+
LOGGER.error(
|
541
|
+
f"Error fetching experiment {experiment['id']}: {experiment_request.text}"
|
422
542
|
)
|
543
|
+
continue
|
544
|
+
experiment = experiment_request.json()
|
545
|
+
risk_eval_count = 0
|
423
546
|
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
)
|
428
|
-
|
429
|
-
status_code=tests_response.status_code,
|
430
|
-
message=message,
|
431
|
-
)
|
432
|
-
tests = tests_response.json()
|
547
|
+
for risk_name in risks.keys():
|
548
|
+
# Check for shutdown before processing each risk
|
549
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
550
|
+
LOGGER.debug("Shutdown requested, stopping risk processing")
|
551
|
+
return
|
433
552
|
|
434
|
-
|
435
|
-
test_id = test["id"]
|
553
|
+
try:
|
436
554
|
if (
|
437
|
-
|
438
|
-
|
555
|
+
risk_name
|
556
|
+
not in experiment.get("source_data", {})
|
557
|
+
.get("evaluation_configuration", {})
|
558
|
+
.keys()
|
439
559
|
):
|
440
|
-
|
441
|
-
f"{
|
442
|
-
json={"test": test, "risk_name": risk_name},
|
443
|
-
timeout=30,
|
560
|
+
LOGGER.debug(
|
561
|
+
f"Skipping experiment {experiment['id']} as it does not have risk {risk_name}"
|
444
562
|
)
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
563
|
+
continue
|
564
|
+
LOGGER.debug(
|
565
|
+
f"checking for tests for experiment {experiment['id']}"
|
566
|
+
)
|
567
|
+
|
568
|
+
try:
|
569
|
+
tests_response = await client.get(
|
570
|
+
f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}/tests?unevaluated-risk={quote_plus(risk_name)}&include-risk-evaluations=true",
|
571
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
572
|
+
)
|
573
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
574
|
+
if shutdown_manager.is_shutdown_requested():
|
575
|
+
LOGGER.debug(
|
576
|
+
f"HTTP error during shutdown (expected): {e}"
|
452
577
|
)
|
453
|
-
|
454
|
-
|
455
|
-
|
578
|
+
return
|
579
|
+
else:
|
580
|
+
LOGGER.error(f"Connection error fetching tests: {e}")
|
581
|
+
continue
|
582
|
+
|
583
|
+
if not tests_response.is_success:
|
584
|
+
message = (
|
585
|
+
tests_response.json().get("message")
|
586
|
+
or tests_response.text
|
587
|
+
)
|
588
|
+
raise ValueError(
|
589
|
+
status_code=tests_response.status_code,
|
590
|
+
message=message,
|
591
|
+
)
|
592
|
+
tests = tests_response.json()
|
593
|
+
|
594
|
+
for test in tests:
|
595
|
+
# Check for shutdown before processing each test
|
596
|
+
if await shutdown_manager.is_async_shutdown_requested():
|
597
|
+
LOGGER.debug(
|
598
|
+
"Shutdown requested, stopping test evaluation"
|
456
599
|
)
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
600
|
+
return
|
601
|
+
|
602
|
+
test_id = test["id"]
|
603
|
+
if (
|
604
|
+
test_id not in queued_evaluations
|
605
|
+
and test.get("response") is not None
|
606
|
+
):
|
607
|
+
try:
|
608
|
+
risk_eval_response = await httpx.AsyncClient().post(
|
609
|
+
f"{config.SNOWGLOBE_CLIENT_URL}/risk-evaluation",
|
610
|
+
json={"test": test, "risk_name": risk_name},
|
611
|
+
timeout=30,
|
612
|
+
)
|
613
|
+
except (
|
614
|
+
httpx.ConnectError,
|
615
|
+
httpx.TimeoutException,
|
616
|
+
) as e:
|
617
|
+
if shutdown_manager.is_shutdown_requested():
|
618
|
+
LOGGER.debug(
|
619
|
+
f"HTTP error during shutdown (expected): {e}"
|
620
|
+
)
|
621
|
+
return
|
622
|
+
else:
|
623
|
+
LOGGER.error(
|
624
|
+
f"Connection error posting risk evaluation: {e}"
|
625
|
+
)
|
626
|
+
continue
|
627
|
+
|
628
|
+
# if risk evaltion response is 429 raise and exception and bail on this batch
|
629
|
+
if (
|
630
|
+
not risk_eval_response.is_success
|
631
|
+
and risk_eval_response.status_code == 429
|
632
|
+
):
|
633
|
+
LOGGER.error(
|
634
|
+
f"Rate limit exceeded for risk evaluation {test['id']}: {risk_eval_response.text}"
|
635
|
+
)
|
636
|
+
raise ValueError(
|
637
|
+
status_code=429,
|
638
|
+
detail=f"Rate limit exceeded for risk evaluation {test['id']}",
|
639
|
+
)
|
640
|
+
queued_evaluations[test_id] = True
|
641
|
+
risk_eval_count += 1
|
642
|
+
except Exception as e:
|
643
|
+
if shutdown_manager.is_shutdown_requested():
|
644
|
+
LOGGER.debug(f"Error during shutdown (expected): {e}")
|
645
|
+
return
|
646
|
+
else:
|
647
|
+
LOGGER.error(f"Error fetching tests: {e}")
|
648
|
+
|
649
|
+
if risk_eval_count > 0:
|
650
|
+
LOGGER.info(
|
651
|
+
f"Processed {risk_eval_count} risk evaluations for experiment {experiment.get('name', 'unknown')} ({experiment['id']})"
|
652
|
+
)
|
653
|
+
|
654
|
+
except Exception as e:
|
655
|
+
# Handle any other errors
|
656
|
+
if shutdown_manager.is_shutdown_requested():
|
657
|
+
LOGGER.debug(f"Error during shutdown (expected): {e}")
|
658
|
+
else:
|
659
|
+
LOGGER.error(f"Unexpected error in poll_for_risk_evaluations: {e}")
|
660
|
+
raise
|
465
661
|
|
466
662
|
|
467
663
|
# Ensure the scheduler shuts down properly on application exit.
|
@@ -599,7 +795,7 @@ async def lifespan(app: FastAPI):
|
|
599
795
|
for app_id in apps.keys():
|
600
796
|
try:
|
601
797
|
connection_test_url = (
|
602
|
-
|
798
|
+
f"{config.CONTROL_PLANE_URL}/api/failed-code-connection-tests"
|
603
799
|
)
|
604
800
|
connection_test_payload = {
|
605
801
|
"appId": app_id,
|
snowglobe/client/src/cli.py
CHANGED
@@ -7,6 +7,7 @@ import sys
|
|
7
7
|
import threading
|
8
8
|
import time
|
9
9
|
import webbrowser
|
10
|
+
from importlib.metadata import version
|
10
11
|
from typing import Optional, Tuple
|
11
12
|
|
12
13
|
import typer
|
@@ -49,6 +50,7 @@ cli_app = typer.Typer(
|
|
49
50
|
help="❄️ Snowglobe CLI - Connect your applications to Snowglobe experiments",
|
50
51
|
add_completion=False,
|
51
52
|
rich_markup_mode="rich",
|
53
|
+
no_args_is_help=False,
|
52
54
|
)
|
53
55
|
|
54
56
|
|
@@ -64,7 +66,7 @@ def setup_global_options(
|
|
64
66
|
cli_state.json_output = json_output
|
65
67
|
|
66
68
|
|
67
|
-
@cli_app.callback()
|
69
|
+
@cli_app.callback(invoke_without_command=True)
|
68
70
|
def main(
|
69
71
|
ctx: typer.Context,
|
70
72
|
verbose: bool = typer.Option(
|
@@ -72,10 +74,26 @@ def main(
|
|
72
74
|
),
|
73
75
|
quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimize output"),
|
74
76
|
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
77
|
+
version_flag: bool = typer.Option(
|
78
|
+
False, "--version", help="Show version and exit", is_flag=True
|
79
|
+
),
|
75
80
|
):
|
76
81
|
"""
|
77
82
|
❄️ Snowglobe CLI - Connect your applications to Snowglobe experiments
|
78
83
|
"""
|
84
|
+
if version_flag:
|
85
|
+
try:
|
86
|
+
pkg_version = version("snowglobe-connect")
|
87
|
+
typer.echo(f"snowglobe-connect {pkg_version}")
|
88
|
+
except Exception:
|
89
|
+
typer.echo("snowglobe-connect (version unknown)")
|
90
|
+
raise typer.Exit()
|
91
|
+
|
92
|
+
# Show help if no command provided and no version flag
|
93
|
+
if not ctx.invoked_subcommand and not version_flag:
|
94
|
+
typer.echo(ctx.get_help())
|
95
|
+
raise typer.Exit()
|
96
|
+
|
79
97
|
setup_global_options(ctx, verbose, quiet, json_output)
|
80
98
|
|
81
99
|
|
@@ -185,16 +203,16 @@ def test(
|
|
185
203
|
|
186
204
|
@cli_app.command()
|
187
205
|
def init(
|
188
|
-
|
189
|
-
None,
|
190
|
-
|
191
|
-
|
192
|
-
|
206
|
+
file: Optional[str] = typer.Option(
|
207
|
+
None,
|
208
|
+
"--file",
|
209
|
+
"-f",
|
210
|
+
help="Path or filename (within project) for the agent wrapper",
|
193
211
|
),
|
194
212
|
# option for stateful agent
|
195
213
|
stateful: bool = typer.Option(
|
196
214
|
False, "--stateful", help="Initialize a stateful agent template"
|
197
|
-
)
|
215
|
+
),
|
198
216
|
):
|
199
217
|
"""
|
200
218
|
Initialize a new Snowglobe agent in the current directory
|
@@ -255,15 +273,37 @@ def init(
|
|
255
273
|
pm.ensure_project_structure()
|
256
274
|
|
257
275
|
# Determine filename
|
258
|
-
if
|
259
|
-
# User provided
|
260
|
-
|
276
|
+
if file:
|
277
|
+
# User provided explicit file path or name
|
278
|
+
from pathlib import Path
|
279
|
+
|
280
|
+
provided_path = Path(os.path.expanduser(file))
|
281
|
+
|
282
|
+
# Ensure .py suffix
|
283
|
+
if provided_path.suffix != ".py":
|
284
|
+
provided_path = provided_path.with_suffix(".py")
|
285
|
+
|
286
|
+
# Normalize to a path within the project root
|
287
|
+
if provided_path.is_absolute():
|
288
|
+
try:
|
289
|
+
relative_path = provided_path.relative_to(pm.project_root)
|
290
|
+
except ValueError:
|
291
|
+
error("--file must be within the current project directory")
|
292
|
+
info(f"Project root: {pm.project_root}")
|
293
|
+
raise typer.Exit(1)
|
294
|
+
else:
|
295
|
+
relative_path = provided_path
|
296
|
+
|
297
|
+
# Store mapping as a POSIX-style relative path string
|
298
|
+
filename = relative_path.as_posix()
|
261
299
|
else:
|
262
300
|
# Generate from app name
|
263
301
|
filename = pm.sanitize_filename(app_name)
|
264
302
|
|
265
|
-
#
|
266
|
-
|
303
|
+
# If filename was auto-generated from app name, find an available one
|
304
|
+
if not file:
|
305
|
+
filename = pm.find_available_filename(filename)
|
306
|
+
|
267
307
|
file_path = pm.project_root / filename
|
268
308
|
|
269
309
|
success(f"Using filename: {filename}")
|
@@ -272,34 +312,33 @@ def init(
|
|
272
312
|
snowglobe_connect_template = stateful_snowglobe_connect_template
|
273
313
|
else:
|
274
314
|
snowglobe_connect_template = stateless_snowglobe_connect_template
|
315
|
+
|
316
|
+
# Ensure parent directories exist if a subpath was provided
|
317
|
+
os.makedirs(file_path.parent, exist_ok=True)
|
318
|
+
|
275
319
|
# Create agent wrapper file
|
276
|
-
|
277
|
-
with
|
278
|
-
|
279
|
-
|
280
|
-
success(f"Created agent wrapper: {filename}")
|
320
|
+
with spinner("Creating agent wrapper"):
|
321
|
+
with open(file_path, "w") as f:
|
322
|
+
f.write(snowglobe_connect_template)
|
323
|
+
success(f"Created agent wrapper: {filename}")
|
281
324
|
|
282
|
-
|
283
|
-
|
284
|
-
|
325
|
+
# Add to mapping
|
326
|
+
pm.add_agent_mapping(filename, app_id, app_name)
|
327
|
+
success("Added mapping to .snowglobe/agents.json")
|
285
328
|
|
286
|
-
|
287
|
-
|
288
|
-
|
329
|
+
console.print("\n[dim]Project structure:[/dim]")
|
330
|
+
console.print("[dim] .snowglobe/agents.json\t- UUID mappings[/dim]")
|
331
|
+
console.print(f"[dim] {filename}\t- Your agent wrapper[/dim]")
|
289
332
|
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
else:
|
300
|
-
info(f"Skipped template creation. You'll need to create {filename} manually")
|
301
|
-
# Still add the mapping even if we skip template
|
302
|
-
pm.add_agent_mapping(filename, app_id, app_name)
|
333
|
+
console.print()
|
334
|
+
info("Next steps:")
|
335
|
+
console.print("1. Edit the process_scenario function in your agent file:")
|
336
|
+
console.print(f" [bold cyan]{filename}[/bold cyan]")
|
337
|
+
console.print("2. Implement your application logic")
|
338
|
+
console.print("3. Test your agent:")
|
339
|
+
console.print(" [bold green]snowglobe-connect test[/bold green]")
|
340
|
+
console.print("4. Start the client:")
|
341
|
+
console.print(" [bold green]snowglobe-connect start[/bold green]")
|
303
342
|
|
304
343
|
console.print()
|
305
344
|
success(f"Agent '{app_name}' initialized successfully! 🎉")
|
@@ -494,6 +533,7 @@ async def completion_fn(request: CompletionRequest) -> CompletionFunctionOutputs
|
|
494
533
|
return CompletionFunctionOutputs(response=response_data.messages[0]["content"])
|
495
534
|
"""
|
496
535
|
|
536
|
+
|
497
537
|
def _save_api_key_to_rc(api_key: str, rc_path: str) -> None:
|
498
538
|
"""Save API key to config file"""
|
499
539
|
# Ensure directory exists
|
@@ -706,15 +746,48 @@ def start(
|
|
706
746
|
"[bold green]🚀 Agent server is live! Processing scenarios...[/bold green]\n"
|
707
747
|
)
|
708
748
|
|
709
|
-
# Handle Ctrl+C gracefully
|
710
|
-
|
711
|
-
graceful_shutdown()
|
712
|
-
|
713
|
-
signal.signal(signal.SIGINT, signal_handler)
|
749
|
+
# Handle Ctrl+C gracefully (legacy handler delegates to smart shutdown internally)
|
750
|
+
signal.signal(signal.SIGINT, graceful_shutdown)
|
714
751
|
|
715
752
|
if not verbose:
|
716
753
|
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
717
754
|
|
755
|
+
# Show helpful links to open the application(s) in the Snowglobe UI
|
756
|
+
try:
|
757
|
+
pm = get_project_manager()
|
758
|
+
agents = pm.list_agents()
|
759
|
+
|
760
|
+
if agents:
|
761
|
+
if len(agents) == 1:
|
762
|
+
_, agent_info = agents[0]
|
763
|
+
app_id = agent_info.get("uuid")
|
764
|
+
app_name = agent_info.get("name", "Application")
|
765
|
+
if app_id:
|
766
|
+
app_url = f"{UI_URL}/applications/{app_id}"
|
767
|
+
console.print(
|
768
|
+
"🔗 Agent connected! Go to your Snowglobe agent and kick off a simulation:"
|
769
|
+
)
|
770
|
+
console.print(f" [link={app_url}]{app_name}[/link]\n")
|
771
|
+
else:
|
772
|
+
console.print(
|
773
|
+
"🔗 Agents connected! Go to your Snowglobe agents and kick off simulations:"
|
774
|
+
)
|
775
|
+
for _, agent_info in agents[:5]:
|
776
|
+
app_id = agent_info.get("uuid")
|
777
|
+
app_name = agent_info.get("name", "Application")
|
778
|
+
if app_id:
|
779
|
+
app_url = f"{UI_URL}/applications/{app_id}"
|
780
|
+
console.print(
|
781
|
+
f" - {app_name}: [link={app_url}]{app_url}[/link]"
|
782
|
+
)
|
783
|
+
if len(agents) > 5:
|
784
|
+
console.print(" (and more...)\n")
|
785
|
+
else:
|
786
|
+
console.print()
|
787
|
+
except Exception:
|
788
|
+
# Do not block startup if we cannot load agents mapping
|
789
|
+
pass
|
790
|
+
|
718
791
|
# Import start_client here to avoid config initialization at module import time
|
719
792
|
from .app import start_client
|
720
793
|
|
@@ -1,7 +1,9 @@
|
|
1
|
+
import asyncio
|
1
2
|
import contextlib
|
2
3
|
import math
|
3
4
|
import os
|
4
5
|
import sys
|
6
|
+
import threading
|
5
7
|
import time
|
6
8
|
from typing import Any, Dict, List, Optional, Tuple
|
7
9
|
|
@@ -25,7 +27,67 @@ class CliState:
|
|
25
27
|
self.json_output = False
|
26
28
|
|
27
29
|
|
30
|
+
class ShutdownManager:
|
31
|
+
"""Coordinate graceful shutdown between signal handler and background jobs"""
|
32
|
+
|
33
|
+
def __init__(self):
|
34
|
+
self.shutdown_event = threading.Event() # For sync code
|
35
|
+
self.async_shutdown_event = None # Created when event loop available
|
36
|
+
self.force_shutdown = False
|
37
|
+
self.shutdown_initiated = False
|
38
|
+
self.active_jobs = set() # Track active background jobs
|
39
|
+
self.active_jobs_lock = threading.Lock()
|
40
|
+
|
41
|
+
def register_active_job(self, job_id: str):
|
42
|
+
"""Register a job as actively running"""
|
43
|
+
with self.active_jobs_lock:
|
44
|
+
self.active_jobs.add(job_id)
|
45
|
+
|
46
|
+
def unregister_active_job(self, job_id: str):
|
47
|
+
"""Unregister a job when it completes"""
|
48
|
+
with self.active_jobs_lock:
|
49
|
+
self.active_jobs.discard(job_id)
|
50
|
+
|
51
|
+
def has_active_jobs(self) -> bool:
|
52
|
+
"""Check if there are any active jobs running"""
|
53
|
+
with self.active_jobs_lock:
|
54
|
+
return len(self.active_jobs) > 0
|
55
|
+
|
56
|
+
def initiate_shutdown(self) -> bool:
|
57
|
+
"""Initiate shutdown. Returns True if this is a force shutdown (second Ctrl+C)"""
|
58
|
+
if self.shutdown_initiated:
|
59
|
+
# Second Ctrl+C - force shutdown
|
60
|
+
self.force_shutdown = True
|
61
|
+
return True
|
62
|
+
else:
|
63
|
+
# First Ctrl+C - start graceful shutdown
|
64
|
+
self.shutdown_initiated = True
|
65
|
+
self.shutdown_event.set()
|
66
|
+
|
67
|
+
# Set async event if event loop exists
|
68
|
+
try:
|
69
|
+
loop = asyncio.get_running_loop()
|
70
|
+
if self.async_shutdown_event is None:
|
71
|
+
self.async_shutdown_event = asyncio.Event()
|
72
|
+
loop.call_soon_threadsafe(self.async_shutdown_event.set)
|
73
|
+
except RuntimeError:
|
74
|
+
pass
|
75
|
+
|
76
|
+
return False
|
77
|
+
|
78
|
+
def is_shutdown_requested(self) -> bool:
|
79
|
+
"""Check if shutdown has been requested"""
|
80
|
+
return self.shutdown_event.is_set()
|
81
|
+
|
82
|
+
async def is_async_shutdown_requested(self) -> bool:
|
83
|
+
"""Async version of shutdown check"""
|
84
|
+
if self.async_shutdown_event is None:
|
85
|
+
return self.shutdown_event.is_set()
|
86
|
+
return self.async_shutdown_event.is_set() or self.shutdown_event.is_set()
|
87
|
+
|
88
|
+
|
28
89
|
cli_state = CliState()
|
90
|
+
shutdown_manager = ShutdownManager()
|
29
91
|
|
30
92
|
|
31
93
|
def get_api_key() -> Optional[str]:
|
@@ -104,13 +166,8 @@ def docs_link(message: str, url: str = "https://www.snowglobe.so/docs") -> None:
|
|
104
166
|
console.print(f"📖 {message}: {url}", style="cyan")
|
105
167
|
|
106
168
|
|
107
|
-
def
|
108
|
-
"""
|
109
|
-
console.print("\n")
|
110
|
-
warning("🛑 Shutting down gracefully...")
|
111
|
-
success("Completing current scenarios")
|
112
|
-
success("Connection closed")
|
113
|
-
|
169
|
+
def show_session_summary():
|
170
|
+
"""Display session summary statistics"""
|
114
171
|
stats = get_shutdown_stats()
|
115
172
|
|
116
173
|
if stats and stats["total_messages"] > 0:
|
@@ -130,6 +187,90 @@ def graceful_shutdown():
|
|
130
187
|
else:
|
131
188
|
success("No scenarios processed during this session")
|
132
189
|
|
190
|
+
|
191
|
+
def begin_graceful_shutdown():
|
192
|
+
"""Coordinate graceful shutdown - runs in background thread"""
|
193
|
+
TIMEOUT_SECONDS = 8
|
194
|
+
|
195
|
+
console.print("\n")
|
196
|
+
warning("🛑 Shutting down gracefully...")
|
197
|
+
success("Completing current scenarios")
|
198
|
+
|
199
|
+
# Wait for active jobs to finish (with timeout)
|
200
|
+
start_time = time.time()
|
201
|
+
while time.time() - start_time < TIMEOUT_SECONDS:
|
202
|
+
if not shutdown_manager.has_active_jobs():
|
203
|
+
break
|
204
|
+
time.sleep(0.1)
|
205
|
+
|
206
|
+
# Check if user pressed Ctrl+C again (force quit)
|
207
|
+
if shutdown_manager.force_shutdown:
|
208
|
+
return # Signal handler will handle force quit
|
209
|
+
|
210
|
+
# Jobs finished or timeout reached
|
211
|
+
elapsed = time.time() - start_time
|
212
|
+
if elapsed >= TIMEOUT_SECONDS:
|
213
|
+
warning(
|
214
|
+
f"Shutdown timeout reached after {TIMEOUT_SECONDS}s, completing shutdown..."
|
215
|
+
)
|
216
|
+
|
217
|
+
success("Connection closed")
|
218
|
+
|
219
|
+
# Show session summary
|
220
|
+
show_session_summary()
|
221
|
+
|
222
|
+
console.print()
|
223
|
+
success("Agent disconnected successfully")
|
224
|
+
|
225
|
+
# Exit cleanly
|
226
|
+
sys.exit(0)
|
227
|
+
|
228
|
+
|
229
|
+
def smart_signal_handler(sig, frame):
|
230
|
+
"""Smart two-stage signal handler with immediate exit when possible"""
|
231
|
+
force_quit = shutdown_manager.initiate_shutdown()
|
232
|
+
|
233
|
+
if force_quit:
|
234
|
+
# Second Ctrl+C - force quit immediately
|
235
|
+
console.print("\n[bold red]🚨 Force shutdown![/bold red]")
|
236
|
+
sys.exit(1)
|
237
|
+
else:
|
238
|
+
# First Ctrl+C - check if we have active work
|
239
|
+
if shutdown_manager.has_active_jobs():
|
240
|
+
# Have active jobs - start graceful shutdown with user feedback
|
241
|
+
console.print(
|
242
|
+
"\n[bold yellow]🛑 Graceful shutdown initiated...[/bold yellow]"
|
243
|
+
)
|
244
|
+
console.print("[dim]Press Ctrl+C again to force quit[/dim]")
|
245
|
+
|
246
|
+
# Start graceful shutdown in background thread
|
247
|
+
threading.Thread(target=begin_graceful_shutdown, daemon=True).start()
|
248
|
+
else:
|
249
|
+
# No active jobs - shutdown immediately
|
250
|
+
console.print("\n[bold blue]🛑 Shutting down...[/bold blue]")
|
251
|
+
|
252
|
+
# Show session summary and exit
|
253
|
+
show_session_summary()
|
254
|
+
console.print()
|
255
|
+
success("Agent disconnected successfully")
|
256
|
+
sys.exit(0)
|
257
|
+
|
258
|
+
|
259
|
+
def graceful_shutdown():
|
260
|
+
"""Handle graceful shutdown with session summary (idempotent, always exits 0).
|
261
|
+
|
262
|
+
This legacy entrypoint avoids toggling internal shutdown state so repeated
|
263
|
+
calls behave consistently in tests and scripts.
|
264
|
+
"""
|
265
|
+
# If there are active jobs, allow them a moment to finish similar to begin_graceful_shutdown,
|
266
|
+
# but without changing the shutdown state in a way that triggers force-exit on reentry.
|
267
|
+
if shutdown_manager.has_active_jobs():
|
268
|
+
console.print("\n[bold yellow]🛑 Graceful shutdown initiated...[/bold yellow]")
|
269
|
+
console.print("[dim]Press Ctrl+C again to force quit[/dim]")
|
270
|
+
|
271
|
+
# Show session summary and exit cleanly
|
272
|
+
console.print("\n[bold blue]🛑 Shutting down...[/bold blue]")
|
273
|
+
show_session_summary()
|
133
274
|
console.print()
|
134
275
|
success("Agent disconnected successfully")
|
135
276
|
sys.exit(0)
|
@@ -174,12 +315,17 @@ def select_stateful_interactive(
|
|
174
315
|
if cli_state.json_output:
|
175
316
|
# For JSON mode, just return the default stateful value
|
176
317
|
return stateful
|
177
|
-
info(
|
178
|
-
|
318
|
+
info(
|
319
|
+
"Some stateful agents such as ones that maintain communication over a websocket or convo specific completion endpoint require stateful integration."
|
320
|
+
)
|
321
|
+
info(
|
322
|
+
"If your agent takes messages and completions on a single completion endpoint regardless of context, you can answer no to the following question."
|
323
|
+
)
|
179
324
|
if Confirm.ask("Would you like to create a new application?"):
|
180
325
|
return True
|
181
326
|
return False
|
182
327
|
|
328
|
+
|
183
329
|
def select_application_interactive(
|
184
330
|
applications: List[Dict[str, Any]],
|
185
331
|
) -> Optional[Dict[str, Any]]:
|
snowglobe/client/src/config.py
CHANGED
@@ -42,7 +42,7 @@ def migrate_rc_file_if_needed():
|
|
42
42
|
|
43
43
|
|
44
44
|
class Config:
|
45
|
-
def __init__(self):
|
45
|
+
def __init__(self, require_api_key=True):
|
46
46
|
# Migrate legacy .snowgloberc if needed
|
47
47
|
migrate_rc_file_if_needed()
|
48
48
|
|
@@ -52,8 +52,16 @@ class Config:
|
|
52
52
|
self.CONCURRENT_HEARTBEATS_PER_INTERVAL = 120
|
53
53
|
self.CONCURRENT_HEARTBEATS_INTERVAL_SECONDS = 60
|
54
54
|
|
55
|
-
# Initialize API key -
|
56
|
-
|
55
|
+
# Initialize API key - optionally raise exception if missing
|
56
|
+
if require_api_key:
|
57
|
+
self.API_KEY = self.get_api_key()
|
58
|
+
else:
|
59
|
+
# For auth command, don't require API key during initialization
|
60
|
+
try:
|
61
|
+
self.API_KEY = self.get_api_key()
|
62
|
+
except ValueError:
|
63
|
+
self.API_KEY = None
|
64
|
+
|
57
65
|
self.APPLICATION_ID = self.get_application_id()
|
58
66
|
self.CONTROL_PLANE_URL = self.get_control_plane_url()
|
59
67
|
self.SNOWGLOBE_CLIENT_URL = self.get_snowglobe_client_url()
|
@@ -210,4 +218,28 @@ class Config:
|
|
210
218
|
return application_id
|
211
219
|
|
212
220
|
|
213
|
-
config = Config()
|
221
|
+
config = Config(require_api_key=False)
|
222
|
+
|
223
|
+
|
224
|
+
def get_api_key_or_raise() -> str:
|
225
|
+
"""Get API key, raising ValueError if not found - for commands that require it"""
|
226
|
+
api_key = config.API_KEY
|
227
|
+
if not api_key:
|
228
|
+
# Try to get it fresh in case it was just set
|
229
|
+
try:
|
230
|
+
api_key = config.get_api_key()
|
231
|
+
config.API_KEY = api_key # Cache it
|
232
|
+
except ValueError:
|
233
|
+
pass
|
234
|
+
|
235
|
+
if not api_key:
|
236
|
+
raise ValueError(
|
237
|
+
"API key is required either passed as an argument or set as an environment variable. \n"
|
238
|
+
"You can set the API key as an environment variable by running: \n"
|
239
|
+
"export SNOWGLOBE_API_KEY=<your_api_key> \n"
|
240
|
+
"or \n"
|
241
|
+
"export GUARDRAILS_API_KEY=<your_api_key> \n"
|
242
|
+
"Or you can create a .snowglobe/config.rc file in the current directory with the line: \n"
|
243
|
+
"SNOWGLOBE_API_KEY=<your_api_key> \n"
|
244
|
+
)
|
245
|
+
return api_key
|
snowglobe/client/src/utils.py
CHANGED
@@ -2,7 +2,7 @@ from logging import getLogger
|
|
2
2
|
|
3
3
|
import httpx
|
4
4
|
|
5
|
-
from .config import config
|
5
|
+
from .config import config, get_api_key_or_raise
|
6
6
|
from .models import SnowglobeData, SnowglobeMessage
|
7
7
|
|
8
8
|
LOGGER = getLogger(__name__)
|
@@ -21,7 +21,7 @@ async def fetch_experiments(app_id: str = None) -> list[dict]:
|
|
21
21
|
experiments_url += f"&appId={config.APPLICATION_ID}"
|
22
22
|
experiments_response = await client.get(
|
23
23
|
experiments_url,
|
24
|
-
headers={"x-api-key":
|
24
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
25
25
|
)
|
26
26
|
|
27
27
|
if not experiments_response.status_code == 200:
|
@@ -78,7 +78,7 @@ async def fetch_messages(*, test) -> list[SnowglobeMessage]:
|
|
78
78
|
# get parent test
|
79
79
|
parent_test_response = await client.get(
|
80
80
|
f"{config.CONTROL_PLANE_URL}/api/experiments/{test['experiment_id']}/tests/{parent_id}",
|
81
|
-
headers={"x-api-key":
|
81
|
+
headers={"x-api-key": get_api_key_or_raise()},
|
82
82
|
)
|
83
83
|
|
84
84
|
if not parent_test_response.status_code == 200:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: snowglobe
|
3
|
-
Version: 0.4.
|
3
|
+
Version: 0.4.2
|
4
4
|
Summary: client server for usage with snowglobe experiments
|
5
5
|
Author-email: Guardrails AI <contact@guardrailsai.com>
|
6
6
|
License: MIT License
|
@@ -49,21 +49,14 @@ The Snowglobe Connect SDK helps you connect your AI agents to Snowglobe. It send
|
|
49
49
|
|
50
50
|
## Installation
|
51
51
|
|
52
|
-
```bash
|
53
|
-
# Paste Guardrails Client or extract from guardrailsrc
|
54
|
-
export GUARDRAILS_TOKEN=$(cat ~/.guardrailsrc| awk -F 'token=' '{print $2}' | awk '{print $1}' | tr -d '\n')
|
55
|
-
```
|
56
|
-
|
57
52
|
```
|
58
53
|
# Install client
|
59
|
-
pip install -
|
60
|
-
--extra-index-url="https://pypi.org/simple" snowglobe-connect
|
54
|
+
pip install snowglobe-connect
|
61
55
|
```
|
62
56
|
|
63
57
|
If using uv, set the `--prerelease=allow` flag
|
64
58
|
```
|
65
|
-
pip install
|
66
|
-
--extra-index-url="https://pypi.org/simple" --prerelease=allow snowglobe-connect
|
59
|
+
uv pip install --prerelease=allow snowglobe-connect
|
67
60
|
```
|
68
61
|
|
69
62
|
|
@@ -0,0 +1,15 @@
|
|
1
|
+
snowglobe/client/__init__.py,sha256=kzp9wPUUYBXqDSKZbfmD4vrAQvrWSW5HOvtpFlEJWfs,353
|
2
|
+
snowglobe/client/src/app.py,sha256=MRV0mzrH4iqkCk1H5pCQZgMj4jkIRNiUx0NkZpzK6tc,39035
|
3
|
+
snowglobe/client/src/cli.py,sha256=E3KZWEHiF38BejVIVgSRMINxKRKOS7p_aemhX4xt90E,27846
|
4
|
+
snowglobe/client/src/cli_utils.py,sha256=NZ018IISxt6hwaxbAraWGoRDMWMsnAeLBhdr3N1ZWDw,17104
|
5
|
+
snowglobe/client/src/config.py,sha256=YRx_AQEZoHaAqk6guTxynIEGV_iJ3wNNGtMmaKsYMbc,10488
|
6
|
+
snowglobe/client/src/models.py,sha256=BX310WrDN9Fd8v68me3XGL_ic1ulvjCrZyIT2ND1eUo,866
|
7
|
+
snowglobe/client/src/project_manager.py,sha256=Ze-qs4dQI2kIV-PmtWZ1b67hMUfsnsMHus90aT8HOow,9970
|
8
|
+
snowglobe/client/src/stats.py,sha256=IdaXroOZBmvLVa_p9pDE6hsxsc7-fBEDnLf8O6Ch0GA,1596
|
9
|
+
snowglobe/client/src/utils.py,sha256=hHOht0hc8fv3OuPTz2Tqs639CzSAF34JTZs5ifKV6YI,3708
|
10
|
+
snowglobe-0.4.2.dist-info/licenses/LICENSE,sha256=S90V6iFU5ZeSg44JQYS1To3pa7ZEobrHc_t483qSKSI,1070
|
11
|
+
snowglobe-0.4.2.dist-info/METADATA,sha256=_0jWIHcgJTgXb84y8G8IOW_5HsO6dVoqn25Avs_uqh4,4483
|
12
|
+
snowglobe-0.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
13
|
+
snowglobe-0.4.2.dist-info/entry_points.txt,sha256=mqx4mTwFPHttjctE2ceYTYWCCIG30Ji2C89aaCYgHcM,71
|
14
|
+
snowglobe-0.4.2.dist-info/top_level.txt,sha256=PoyYihnCBjRyjeIT19yBcE47JTe7i1OwRXvJ4d5EohM,10
|
15
|
+
snowglobe-0.4.2.dist-info/RECORD,,
|
snowglobe-0.4.0.dist-info/RECORD
DELETED
@@ -1,15 +0,0 @@
|
|
1
|
-
snowglobe/client/__init__.py,sha256=kzp9wPUUYBXqDSKZbfmD4vrAQvrWSW5HOvtpFlEJWfs,353
|
2
|
-
snowglobe/client/src/app.py,sha256=PZCTwF3n_2Bi825dOflGeN8GSjuf5ebKLSxV7S90TxA,30289
|
3
|
-
snowglobe/client/src/cli.py,sha256=f8AKB0mAcMK-63aZczs4LxnB33RqoxKPEnv2w1lgu1c,25159
|
4
|
-
snowglobe/client/src/cli_utils.py,sha256=PRWpbrALfTc3fpAXl32pfyZWkLYi_3N03csTQR1wnjc,11911
|
5
|
-
snowglobe/client/src/config.py,sha256=HAJD7RkO6IB_mFkmGLpy9Ma3sB0KpZE7zmGsa9K__iE,9284
|
6
|
-
snowglobe/client/src/models.py,sha256=BX310WrDN9Fd8v68me3XGL_ic1ulvjCrZyIT2ND1eUo,866
|
7
|
-
snowglobe/client/src/project_manager.py,sha256=Ze-qs4dQI2kIV-PmtWZ1b67hMUfsnsMHus90aT8HOow,9970
|
8
|
-
snowglobe/client/src/stats.py,sha256=IdaXroOZBmvLVa_p9pDE6hsxsc7-fBEDnLf8O6Ch0GA,1596
|
9
|
-
snowglobe/client/src/utils.py,sha256=U0nQjTjO28OghG7lV6BuI_2MkcsOhu31Nz00nCJU4sM,3670
|
10
|
-
snowglobe-0.4.0.dist-info/licenses/LICENSE,sha256=S90V6iFU5ZeSg44JQYS1To3pa7ZEobrHc_t483qSKSI,1070
|
11
|
-
snowglobe-0.4.0.dist-info/METADATA,sha256=OEuNnqFkNHhu3z0cgORX17kyKGOhoAvo7IxUfkopMTM,4921
|
12
|
-
snowglobe-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
13
|
-
snowglobe-0.4.0.dist-info/entry_points.txt,sha256=mqx4mTwFPHttjctE2ceYTYWCCIG30Ji2C89aaCYgHcM,71
|
14
|
-
snowglobe-0.4.0.dist-info/top_level.txt,sha256=PoyYihnCBjRyjeIT19yBcE47JTe7i1OwRXvJ4d5EohM,10
|
15
|
-
snowglobe-0.4.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|