cloudbrain-server 1.1.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,717 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CloudBrain Token Management Tool
4
+ Generate and manage authentication tokens for AI agents
5
+ """
6
+
7
+ import sqlite3
8
+ import secrets
9
+ import hashlib
10
+ import json
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Optional, Dict, List
14
+ from db_config import get_db_connection, is_postgres, is_sqlite
15
+
16
+
17
+ class TokenManager:
18
+ """Manage authentication tokens for CloudBrain"""
19
+
20
+ def __init__(self, db_path: str = 'ai_db/cloudbrain.db'):
21
+ self.db_path = db_path
22
+ self._ensure_tables_exist()
23
+
24
+ def _get_connection(self):
25
+ """Get database connection"""
26
+ conn = get_db_connection()
27
+ if is_sqlite():
28
+ conn.row_factory = sqlite3.Row
29
+ return conn
30
+
31
+ def _ensure_tables_exist(self):
32
+ """Ensure authorization tables exist"""
33
+ # Skip schema execution for PostgreSQL - tables are already created
34
+ if is_postgres():
35
+ conn = self._get_connection()
36
+ cursor = conn.cursor()
37
+
38
+ # Read and execute PostgreSQL schema
39
+ schema_path = Path(__file__).parent / 'server_authorization_schema_postgres.sql'
40
+ if schema_path.exists():
41
+ with open(schema_path, 'r') as f:
42
+ schema_sql = f.read()
43
+
44
+ for statement in schema_sql.split(';'):
45
+ statement = statement.strip()
46
+ if statement and not statement.startswith('--'):
47
+ try:
48
+ cursor.execute(statement)
49
+ except Exception as e:
50
+ if 'already exists' not in str(e) and 'duplicate' not in str(e).lower():
51
+ print(f"⚠️ Error executing schema: {e}")
52
+
53
+ conn.commit()
54
+ conn.close()
55
+ return
56
+
57
+ conn = self._get_connection()
58
+ cursor = conn.cursor()
59
+
60
+ # Read and execute schema
61
+ schema_path = Path(__file__).parent / 'server_authorization_schema.sql'
62
+ if schema_path.exists():
63
+ with open(schema_path, 'r') as f:
64
+ schema_sql = f.read()
65
+
66
+ for statement in schema_sql.split(';'):
67
+ statement = statement.strip()
68
+ if statement and not statement.startswith('--'):
69
+ try:
70
+ cursor.execute(statement)
71
+ except Exception as e:
72
+ if 'already exists' not in str(e):
73
+ print(f"⚠️ Error executing schema: {e}")
74
+
75
+ conn.commit()
76
+ conn.close()
77
+
78
+ def generate_token(self, ai_id: int, expires_days: int = 30, description: str = "") -> Dict:
79
+ """
80
+ Generate a new authentication token for an AI
81
+
82
+ Args:
83
+ ai_id: AI ID to generate token for
84
+ expires_days: Number of days until token expires (default: 30)
85
+ description: Optional description for the token
86
+
87
+ Returns:
88
+ Dictionary with token info or error
89
+ """
90
+ try:
91
+ # Check if AI exists
92
+ conn = self._get_connection()
93
+ cursor = conn.cursor()
94
+
95
+ if is_sqlite():
96
+ cursor.execute("SELECT id, name FROM ai_profiles WHERE id = ?", (ai_id,))
97
+ else:
98
+ cursor.execute("SELECT id, name FROM ai_profiles WHERE id = %s", (ai_id,))
99
+ ai_profile = cursor.fetchone()
100
+
101
+ if not ai_profile:
102
+ conn.close()
103
+ return {
104
+ 'success': False,
105
+ 'error': f'AI {ai_id} not found'
106
+ }
107
+
108
+ # Generate secure random token
109
+ token = secrets.token_urlsafe(32)
110
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
111
+ token_prefix = f"sk_live_{token[:8]}"
112
+
113
+ # Calculate expiration
114
+ expires_at = datetime.now() + timedelta(days=expires_days)
115
+
116
+ # Deactivate old tokens for this AI
117
+ if is_sqlite():
118
+ cursor.execute(
119
+ "UPDATE ai_auth_tokens SET is_active = 0 WHERE ai_id = ?",
120
+ (ai_id,)
121
+ )
122
+ else:
123
+ cursor.execute(
124
+ "UPDATE ai_auth_tokens SET is_active = FALSE WHERE ai_id = %s",
125
+ (ai_id,)
126
+ )
127
+
128
+ # Insert new token
129
+ if is_sqlite():
130
+ cursor.execute("""
131
+ INSERT INTO ai_auth_tokens (ai_id, token_hash, token_prefix, expires_at, description)
132
+ VALUES (?, ?, ?, ?, ?)
133
+ """, (ai_id, token_hash, token_prefix, expires_at.isoformat(), description))
134
+ else:
135
+ cursor.execute("""
136
+ INSERT INTO ai_auth_tokens (ai_id, token_hash, token_prefix, expires_at, description)
137
+ VALUES (%s, %s, %s, %s, %s)
138
+ """, (ai_id, token_hash, token_prefix, expires_at.isoformat(), description))
139
+
140
+ conn.commit()
141
+ conn.close()
142
+
143
+ print(f"✅ Token generated for AI {ai_id} ({ai_profile['name']})")
144
+ print(f" Token: {token}")
145
+ print(f" Prefix: {token_prefix}")
146
+ print(f" Expires: {expires_at.isoformat()}")
147
+ print(f" Description: {description or 'No description'}")
148
+
149
+ return {
150
+ 'success': True,
151
+ 'token': token,
152
+ 'token_prefix': token_prefix,
153
+ 'token_hash': token_hash,
154
+ 'ai_id': ai_id,
155
+ 'ai_name': ai_profile['name'],
156
+ 'expires_at': expires_at.isoformat(),
157
+ 'description': description
158
+ }
159
+
160
+ except Exception as e:
161
+ print(f"❌ Error generating token: {e}")
162
+ return {
163
+ 'success': False,
164
+ 'error': str(e)
165
+ }
166
+
167
+ def validate_token(self, token: str) -> Dict:
168
+ """
169
+ Validate an authentication token
170
+
171
+ Args:
172
+ token: Token to validate
173
+
174
+ Returns:
175
+ Dictionary with validation result
176
+ """
177
+ try:
178
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
179
+
180
+ conn = self._get_connection()
181
+ cursor = conn.cursor()
182
+
183
+ if is_sqlite():
184
+ cursor.execute("""
185
+ SELECT t.*, p.name as ai_name, p.expertise
186
+ FROM ai_auth_tokens t
187
+ JOIN ai_profiles p ON t.ai_id = p.id
188
+ WHERE t.token_hash = ? AND t.is_active = 1
189
+ """, (token_hash,))
190
+ else:
191
+ cursor.execute("""
192
+ SELECT t.*, p.name as ai_name, p.expertise
193
+ FROM ai_auth_tokens t
194
+ JOIN ai_profiles p ON t.ai_id = p.id
195
+ WHERE t.token_hash = %s AND t.is_active = TRUE
196
+ """, (token_hash,))
197
+
198
+ token_record = cursor.fetchone()
199
+
200
+ if not token_record:
201
+ conn.close()
202
+ return {
203
+ 'valid': False,
204
+ 'error': 'Invalid or expired token'
205
+ }
206
+
207
+ # Check expiration
208
+ expires_at = datetime.fromisoformat(token_record['expires_at'])
209
+ if datetime.now() > expires_at:
210
+ conn.close()
211
+ return {
212
+ 'valid': False,
213
+ 'error': 'Token has expired'
214
+ }
215
+
216
+ # Update last used timestamp
217
+ if is_sqlite():
218
+ cursor.execute(
219
+ "UPDATE ai_auth_tokens SET last_used_at = ? WHERE id = ?",
220
+ (datetime.now().isoformat(), token_record['id'])
221
+ )
222
+ else:
223
+ cursor.execute(
224
+ "UPDATE ai_auth_tokens SET last_used_at = %s WHERE id = %s",
225
+ (datetime.now().isoformat(), token_record['id'])
226
+ )
227
+
228
+ conn.commit()
229
+ conn.close()
230
+
231
+ return {
232
+ 'valid': True,
233
+ 'ai_id': token_record['ai_id'],
234
+ 'ai_name': token_record['ai_name'],
235
+ 'expertise': token_record['expertise'],
236
+ 'token_prefix': token_record['token_prefix'],
237
+ 'expires_at': token_record['expires_at']
238
+ }
239
+
240
+ except Exception as e:
241
+ print(f"❌ Error validating token: {e}")
242
+ return {
243
+ 'valid': False,
244
+ 'error': str(e)
245
+ }
246
+
247
+ def revoke_token(self, token_prefix: str) -> Dict:
248
+ """
249
+ Revoke an authentication token
250
+
251
+ Args:
252
+ token_prefix: Token prefix to revoke (e.g., "sk_live_abc12345")
253
+
254
+ Returns:
255
+ Dictionary with revocation result
256
+ """
257
+ try:
258
+ conn = self._get_connection()
259
+ cursor = conn.cursor()
260
+
261
+ if is_sqlite():
262
+ cursor.execute("""
263
+ UPDATE ai_auth_tokens
264
+ SET is_active = 0
265
+ WHERE token_prefix = ?
266
+ """, (token_prefix,))
267
+ else:
268
+ cursor.execute("""
269
+ UPDATE ai_auth_tokens
270
+ SET is_active = FALSE
271
+ WHERE token_prefix = %s
272
+ """, (token_prefix,))
273
+
274
+ rows_affected = cursor.rowcount
275
+ conn.commit()
276
+ conn.close()
277
+
278
+ if rows_affected > 0:
279
+ print(f"✅ Token {token_prefix} revoked")
280
+ return {
281
+ 'success': True,
282
+ 'revoked': True
283
+ }
284
+ else:
285
+ print(f"⚠️ Token {token_prefix} not found")
286
+ return {
287
+ 'success': False,
288
+ 'error': 'Token not found'
289
+ }
290
+
291
+ except Exception as e:
292
+ print(f"❌ Error revoking token: {e}")
293
+ return {
294
+ 'success': False,
295
+ 'error': str(e)
296
+ }
297
+
298
+ def list_tokens(self, ai_id: Optional[int] = None) -> List[Dict]:
299
+ """
300
+ List all tokens or tokens for a specific AI
301
+
302
+ Args:
303
+ ai_id: Optional AI ID to filter by
304
+
305
+ Returns:
306
+ List of token information
307
+ """
308
+ try:
309
+ conn = self._get_connection()
310
+ cursor = conn.cursor()
311
+
312
+ if ai_id:
313
+ if is_sqlite():
314
+ cursor.execute("""
315
+ SELECT t.*, p.name as ai_name
316
+ FROM ai_auth_tokens t
317
+ JOIN ai_profiles p ON t.ai_id = p.id
318
+ WHERE t.ai_id = ?
319
+ ORDER BY t.created_at DESC
320
+ """, (ai_id,))
321
+ else:
322
+ cursor.execute("""
323
+ SELECT t.*, p.name as ai_name
324
+ FROM ai_auth_tokens t
325
+ JOIN ai_profiles p ON t.ai_id = p.id
326
+ WHERE t.ai_id = %s
327
+ ORDER BY t.created_at DESC
328
+ """, (ai_id,))
329
+ else:
330
+ cursor.execute("""
331
+ SELECT t.*, p.name as ai_name
332
+ FROM ai_auth_tokens t
333
+ JOIN ai_profiles p ON t.ai_id = p.id
334
+ ORDER BY t.created_at DESC
335
+ """)
336
+
337
+ tokens = [dict(row) for row in cursor.fetchall()]
338
+ conn.close()
339
+
340
+ return tokens
341
+
342
+ except Exception as e:
343
+ print(f"❌ Error listing tokens: {e}")
344
+ return []
345
+
346
+ def grant_project_permission(self, ai_id: int, project: str, role: str = 'member', granted_by: int = None) -> Dict:
347
+ """
348
+ Grant project permission to an AI
349
+
350
+ Args:
351
+ ai_id: AI ID to grant permission to
352
+ project: Project name
353
+ role: Role (admin, member, viewer, contributor)
354
+ granted_by: AI ID of admin granting permission
355
+
356
+ Returns:
357
+ Dictionary with grant result
358
+ """
359
+ try:
360
+ conn = self._get_connection()
361
+ cursor = conn.cursor()
362
+
363
+ # Check if AI exists
364
+ if is_sqlite():
365
+ cursor.execute("SELECT name FROM ai_profiles WHERE id = ?", (ai_id,))
366
+ else:
367
+ cursor.execute("SELECT name FROM ai_profiles WHERE id = %s", (ai_id,))
368
+ ai_profile = cursor.fetchone()
369
+
370
+ if not ai_profile:
371
+ conn.close()
372
+ return {
373
+ 'success': False,
374
+ 'error': f'AI {ai_id} not found'
375
+ }
376
+
377
+ # Check if permission already exists
378
+ if is_sqlite():
379
+ cursor.execute("""
380
+ SELECT id FROM ai_project_permissions
381
+ WHERE ai_id = ? AND project = ?
382
+ """, (ai_id, project))
383
+ else:
384
+ cursor.execute("""
385
+ SELECT id FROM ai_project_permissions
386
+ WHERE ai_id = %s AND project = %s
387
+ """, (ai_id, project))
388
+
389
+ existing = cursor.fetchone()
390
+
391
+ if existing:
392
+ # Update existing permission
393
+ if is_sqlite():
394
+ cursor.execute("""
395
+ UPDATE ai_project_permissions
396
+ SET role = ?, granted_by = ?
397
+ WHERE ai_id = ? AND project = ?
398
+ """, (role, granted_by, ai_id, project))
399
+ else:
400
+ cursor.execute("""
401
+ UPDATE ai_project_permissions
402
+ SET role = %s, granted_by = %s
403
+ WHERE ai_id = %s AND project = %s
404
+ """, (role, granted_by, ai_id, project))
405
+ print(f"✅ Updated permission for AI {ai_id} on project {project}: {role}")
406
+ else:
407
+ # Insert new permission
408
+ if is_sqlite():
409
+ cursor.execute("""
410
+ INSERT INTO ai_project_permissions (ai_id, project, role, granted_by)
411
+ VALUES (?, ?, ?, ?)
412
+ """, (ai_id, project, role, granted_by))
413
+ else:
414
+ cursor.execute("""
415
+ INSERT INTO ai_project_permissions (ai_id, project, role, granted_by)
416
+ VALUES (%s, %s, %s, %s)
417
+ """, (ai_id, project, role, granted_by))
418
+ print(f"✅ Granted permission for AI {ai_id} on project {project}: {role}")
419
+
420
+ conn.commit()
421
+ conn.close()
422
+
423
+ return {
424
+ 'success': True,
425
+ 'ai_id': ai_id,
426
+ 'project': project,
427
+ 'role': role
428
+ }
429
+
430
+ except Exception as e:
431
+ print(f"❌ Error granting permission: {e}")
432
+ return {
433
+ 'success': False,
434
+ 'error': str(e)
435
+ }
436
+
437
+ def revoke_project_permission(self, ai_id: int, project: str) -> Dict:
438
+ """
439
+ Revoke project permission from an AI
440
+
441
+ Args:
442
+ ai_id: AI ID to revoke permission from
443
+ project: Project name
444
+
445
+ Returns:
446
+ Dictionary with revocation result
447
+ """
448
+ try:
449
+ conn = self._get_connection()
450
+ cursor = conn.cursor()
451
+
452
+ if is_sqlite():
453
+ cursor.execute("""
454
+ DELETE FROM ai_project_permissions
455
+ WHERE ai_id = ? AND project = ?
456
+ """, (ai_id, project))
457
+ else:
458
+ cursor.execute("""
459
+ DELETE FROM ai_project_permissions
460
+ WHERE ai_id = %s AND project = %s
461
+ """, (ai_id, project))
462
+
463
+ rows_affected = cursor.rowcount
464
+ conn.commit()
465
+ conn.close()
466
+
467
+ if rows_affected > 0:
468
+ print(f"✅ Revoked permission for AI {ai_id} on project {project}")
469
+ return {
470
+ 'success': True,
471
+ 'revoked': True
472
+ }
473
+ else:
474
+ print(f"⚠️ No permission found for AI {ai_id} on project {project}")
475
+ return {
476
+ 'success': False,
477
+ 'error': 'Permission not found'
478
+ }
479
+
480
+ except Exception as e:
481
+ print(f"❌ Error revoking permission: {e}")
482
+ return {
483
+ 'success': False,
484
+ 'error': str(e)
485
+ }
486
+
487
+ def list_permissions(self, ai_id: Optional[int] = None, project: Optional[str] = None) -> List[Dict]:
488
+ """
489
+ List project permissions
490
+
491
+ Args:
492
+ ai_id: Optional AI ID to filter by
493
+ project: Optional project name to filter by
494
+
495
+ Returns:
496
+ List of permission information
497
+ """
498
+ try:
499
+ conn = self._get_connection()
500
+ cursor = conn.cursor()
501
+
502
+ if ai_id and project:
503
+ if is_sqlite():
504
+ cursor.execute("""
505
+ SELECT pp.*, p.name as ai_name
506
+ FROM ai_project_permissions pp
507
+ JOIN ai_profiles p ON pp.ai_id = p.id
508
+ WHERE pp.ai_id = ? AND pp.project = ?
509
+ ORDER BY pp.created_at DESC
510
+ """, (ai_id, project))
511
+ else:
512
+ cursor.execute("""
513
+ SELECT pp.*, p.name as ai_name
514
+ FROM ai_project_permissions pp
515
+ JOIN ai_profiles p ON pp.ai_id = p.id
516
+ WHERE pp.ai_id = %s AND pp.project = %s
517
+ ORDER BY pp.created_at DESC
518
+ """, (ai_id, project))
519
+ elif ai_id:
520
+ if is_sqlite():
521
+ cursor.execute("""
522
+ SELECT pp.*, p.name as ai_name
523
+ FROM ai_project_permissions pp
524
+ JOIN ai_profiles p ON pp.ai_id = p.id
525
+ WHERE pp.ai_id = ?
526
+ ORDER BY pp.created_at DESC
527
+ """, (ai_id,))
528
+ else:
529
+ cursor.execute("""
530
+ SELECT pp.*, p.name as ai_name
531
+ FROM ai_project_permissions pp
532
+ JOIN ai_profiles p ON pp.ai_id = p.id
533
+ WHERE pp.ai_id = %s
534
+ ORDER BY pp.created_at DESC
535
+ """, (ai_id,))
536
+ elif project:
537
+ if is_sqlite():
538
+ cursor.execute("""
539
+ SELECT pp.*, p.name as ai_name
540
+ FROM ai_project_permissions pp
541
+ JOIN ai_profiles p ON pp.ai_id = p.id
542
+ WHERE pp.project = ?
543
+ ORDER BY pp.created_at DESC
544
+ """, (project,))
545
+ else:
546
+ cursor.execute("""
547
+ SELECT pp.*, p.name as ai_name
548
+ FROM ai_project_permissions pp
549
+ JOIN ai_profiles p ON pp.ai_id = p.id
550
+ WHERE pp.project = %s
551
+ ORDER BY pp.created_at DESC
552
+ """, (project,))
553
+ else:
554
+ cursor.execute("""
555
+ SELECT pp.*, p.name as ai_name
556
+ FROM ai_project_permissions pp
557
+ JOIN ai_profiles p ON pp.ai_id = p.id
558
+ ORDER BY pp.created_at DESC
559
+ """)
560
+
561
+ permissions = [dict(row) for row in cursor.fetchall()]
562
+ conn.close()
563
+
564
+ return permissions
565
+
566
+ except Exception as e:
567
+ print(f"❌ Error listing permissions: {e}")
568
+ return []
569
+
570
+ def check_project_permission(self, ai_id: int, project: str) -> Dict:
571
+ """
572
+ Check if an AI has permission for a project
573
+
574
+ Args:
575
+ ai_id: AI ID to check
576
+ project: Project name to check
577
+
578
+ Returns:
579
+ Dictionary with permission status and role
580
+ """
581
+ try:
582
+ conn = self._get_connection()
583
+ cursor = conn.cursor()
584
+
585
+ if is_sqlite():
586
+ cursor.execute("""
587
+ SELECT role, created_at
588
+ FROM ai_project_permissions
589
+ WHERE ai_id = ? AND project = ?
590
+ """, (ai_id, project))
591
+ else:
592
+ cursor.execute("""
593
+ SELECT role, created_at
594
+ FROM ai_project_permissions
595
+ WHERE ai_id = %s AND project = %s
596
+ """, (ai_id, project))
597
+
598
+ permission = cursor.fetchone()
599
+ conn.close()
600
+
601
+ if permission:
602
+ return {
603
+ 'has_permission': True,
604
+ 'role': permission['role'],
605
+ 'granted_at': permission['created_at']
606
+ }
607
+ else:
608
+ return {
609
+ 'has_permission': False,
610
+ 'role': None
611
+ }
612
+
613
+ except Exception as e:
614
+ print(f"❌ Error checking project permission: {e}")
615
+ return {
616
+ 'has_permission': False,
617
+ 'role': None,
618
+ 'error': str(e)
619
+ }
620
+
621
+ def log_authentication(self, ai_id: int, project: Optional[str] = None,
622
+ success: bool = True, details: str = "") -> Dict:
623
+ """
624
+ Log authentication attempt to audit table
625
+
626
+ Args:
627
+ ai_id: AI ID attempting authentication
628
+ project: Project name (optional)
629
+ success: Whether authentication succeeded
630
+ details: Additional details about the attempt
631
+
632
+ Returns:
633
+ Dictionary with log result
634
+ """
635
+ try:
636
+ conn = self._get_connection()
637
+ cursor = conn.cursor()
638
+
639
+ # Get AI name for logging
640
+ cursor.execute("SELECT name FROM ai_profiles WHERE id = ?", (ai_id,))
641
+ ai_profile = cursor.fetchone()
642
+ ai_name = ai_profile['name'] if ai_profile else 'Unknown'
643
+
644
+ if is_sqlite():
645
+ cursor.execute("""
646
+ INSERT INTO ai_auth_audit (ai_id, ai_name, project, success, details, created_at)
647
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
648
+ """, (ai_id, ai_name, project, 1 if success else 0, details))
649
+ else:
650
+ cursor.execute("""
651
+ INSERT INTO ai_auth_audit (ai_id, ai_name, project, success, details, created_at)
652
+ VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
653
+ """, (ai_id, ai_name, project, success, details))
654
+
655
+ conn.commit()
656
+ conn.close()
657
+
658
+ return {
659
+ 'success': True,
660
+ 'ai_id': ai_id,
661
+ 'project': project,
662
+ 'success': success
663
+ }
664
+
665
+ except Exception as e:
666
+ print(f"❌ Error logging authentication [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]: {e}")
667
+ return {
668
+ 'success': False,
669
+ 'error': str(e)
670
+ }
671
+
672
+
673
+ def main():
674
+ """Example usage of token manager"""
675
+ manager = TokenManager()
676
+
677
+ print("=" * 70)
678
+ print("🔐 CloudBrain Token Manager")
679
+ print("=" * 70)
680
+ print()
681
+
682
+ # Example: Generate token for AI 19 (GLM-4.7)
683
+ print("📝 Example 1: Generate token for AI 19")
684
+ result = manager.generate_token(
685
+ ai_id=19,
686
+ expires_days=30,
687
+ description="CloudBrain development token"
688
+ )
689
+ print()
690
+
691
+ # Example: Validate token
692
+ if result['success']:
693
+ print("📝 Example 2: Validate token")
694
+ validation = manager.validate_token(result['token'])
695
+ print(f" Valid: {validation['valid']}")
696
+ print()
697
+
698
+ # Example: Grant project permission
699
+ print("📝 Example 3: Grant project permission")
700
+ manager.grant_project_permission(
701
+ ai_id=19,
702
+ project='cloudbrain',
703
+ role='admin',
704
+ granted_by=1
705
+ )
706
+ print()
707
+
708
+ # Example: List all tokens
709
+ print("📝 Example 4: List all tokens")
710
+ tokens = manager.list_tokens()
711
+ for token in tokens[:5]:
712
+ print(f" {token['ai_name']}: {token['token_prefix']} (expires: {token['expires_at']})")
713
+ print()
714
+
715
+
716
+ if __name__ == "__main__":
717
+ main()