codetether 1.2.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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,576 @@
1
+ """
2
+ HashiCorp Vault client for A2A Server.
3
+
4
+ Provides secure storage for LLM provider API keys and other secrets.
5
+ API keys are stored per-user, tied to their Keycloak identity.
6
+
7
+ Supports both Kubernetes auth (in-cluster) and token auth (local dev).
8
+
9
+ Configuration (environment variables):
10
+ VAULT_ADDR: Vault server URL (default: http://vault.vault.svc.cluster.local:8200)
11
+ VAULT_TOKEN: Vault token for authentication (local dev)
12
+ VAULT_ROLE: Kubernetes auth role (default: a2a-server)
13
+ VAULT_MOUNT_PATH: KV secrets engine mount path (default: secret)
14
+ VAULT_API_KEYS_PATH: Base path for API keys (default: a2a/users)
15
+ """
16
+
17
+ import asyncio
18
+ import logging
19
+ import os
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ import aiohttp
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Vault configuration
28
+ VAULT_ADDR = os.environ.get(
29
+ 'VAULT_ADDR', 'http://vault.vault.svc.cluster.local:8200'
30
+ )
31
+ VAULT_TOKEN = os.environ.get('VAULT_TOKEN', '')
32
+ VAULT_ROLE = os.environ.get('VAULT_ROLE', 'a2a-server')
33
+ VAULT_MOUNT_PATH = os.environ.get('VAULT_MOUNT_PATH', 'secret')
34
+ VAULT_API_KEYS_BASE_PATH = os.environ.get('VAULT_API_KEYS_PATH', 'a2a/users')
35
+
36
+ # Kubernetes service account token path
37
+ K8S_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'
38
+
39
+ # Module state
40
+ _vault_token: Optional[str] = None
41
+ _token_lock = asyncio.Lock()
42
+
43
+
44
+ # Known providers with their display names and configurations
45
+ KNOWN_PROVIDERS = {
46
+ 'anthropic': {
47
+ 'name': 'Anthropic',
48
+ 'npm': '@ai-sdk/anthropic',
49
+ 'description': 'Claude models (Opus, Sonnet, Haiku)',
50
+ },
51
+ 'openai': {
52
+ 'name': 'OpenAI',
53
+ 'npm': '@ai-sdk/openai',
54
+ 'description': 'GPT-4, GPT-3.5, and other OpenAI models',
55
+ },
56
+ 'google': {
57
+ 'name': 'Google AI',
58
+ 'npm': '@ai-sdk/google',
59
+ 'description': 'Gemini and other Google AI models',
60
+ },
61
+ 'minimax': {
62
+ 'name': 'MiniMax',
63
+ 'npm': '@ai-sdk/anthropic',
64
+ 'base_url': 'https://api.minimax.io/anthropic/v1',
65
+ 'description': 'MiniMax M2 models (Anthropic-compatible API)',
66
+ },
67
+ 'minimax-m2': {
68
+ 'name': 'MiniMax M2',
69
+ 'npm': '@ai-sdk/anthropic',
70
+ 'base_url': 'https://api.minimax.io/anthropic/v1',
71
+ 'description': 'MiniMax M2.1 early access',
72
+ 'models': {
73
+ 'MiniMax-M2.1': {
74
+ 'name': 'MiniMax M2.1',
75
+ 'reasoning': True,
76
+ 'temperature': True,
77
+ 'tool_call': True,
78
+ },
79
+ 'MiniMax-M2': {
80
+ 'name': 'MiniMax M2',
81
+ 'reasoning': True,
82
+ 'temperature': True,
83
+ 'tool_call': True,
84
+ },
85
+ },
86
+ },
87
+ 'azure': {
88
+ 'name': 'Azure OpenAI',
89
+ 'npm': '@ai-sdk/azure',
90
+ 'description': 'Azure-hosted OpenAI models',
91
+ },
92
+ 'azure-anthropic': {
93
+ 'name': 'Azure AI Foundry (Anthropic)',
94
+ 'npm': '@ai-sdk/anthropic',
95
+ 'description': 'Claude models via Azure AI Foundry',
96
+ 'requires_base_url': True,
97
+ },
98
+ 'deepseek': {
99
+ 'name': 'DeepSeek',
100
+ 'npm': '@ai-sdk/openai-compatible',
101
+ 'base_url': 'https://api.deepseek.com/v1',
102
+ 'description': 'DeepSeek Coder and Chat models',
103
+ },
104
+ 'groq': {
105
+ 'name': 'Groq',
106
+ 'npm': '@ai-sdk/groq',
107
+ 'description': 'Fast inference with Groq',
108
+ },
109
+ 'github-copilot': {
110
+ 'name': 'GitHub Copilot',
111
+ 'npm': '@ai-sdk/github-copilot',
112
+ 'description': 'GitHub Copilot (requires OAuth)',
113
+ 'auth_type': 'oauth',
114
+ },
115
+ 'zai-coding-plan': {
116
+ 'name': 'Z.AI Coding Plan',
117
+ 'npm': '@ai-sdk/openai-compatible',
118
+ 'base_url': 'https://api.z.ai/v1',
119
+ 'description': 'Z.AI GLM models',
120
+ },
121
+ }
122
+
123
+
124
+ class VaultClient:
125
+ """Async client for HashiCorp Vault."""
126
+
127
+ def __init__(self, addr: str = VAULT_ADDR):
128
+ self.addr = addr.rstrip('/')
129
+ self._session: Optional[aiohttp.ClientSession] = None
130
+ self._token: Optional[str] = None
131
+
132
+ async def _get_session(self) -> aiohttp.ClientSession:
133
+ """Get or create HTTP session."""
134
+ if self._session is None or self._session.closed:
135
+ self._session = aiohttp.ClientSession(
136
+ timeout=aiohttp.ClientTimeout(total=30)
137
+ )
138
+ return self._session
139
+
140
+ async def close(self):
141
+ """Close the HTTP session."""
142
+ if self._session and not self._session.closed:
143
+ await self._session.close()
144
+
145
+ async def _get_token(self) -> Optional[str]:
146
+ """Get Vault token, using Kubernetes auth if available."""
147
+ global _vault_token
148
+
149
+ # Return cached token if available
150
+ if self._token:
151
+ return self._token
152
+
153
+ async with _token_lock:
154
+ # Check again after acquiring lock
155
+ if _vault_token:
156
+ self._token = _vault_token
157
+ return self._token
158
+
159
+ # Try environment variable first
160
+ if VAULT_TOKEN:
161
+ self._token = VAULT_TOKEN
162
+ _vault_token = VAULT_TOKEN
163
+ logger.info('Using Vault token from environment')
164
+ return self._token
165
+
166
+ # Try Kubernetes auth
167
+ if os.path.exists(K8S_TOKEN_PATH):
168
+ try:
169
+ with open(K8S_TOKEN_PATH, 'r') as f:
170
+ k8s_token = f.read().strip()
171
+
172
+ session = await self._get_session()
173
+ async with session.post(
174
+ f'{self.addr}/v1/auth/kubernetes/login',
175
+ json={'jwt': k8s_token, 'role': VAULT_ROLE},
176
+ ) as resp:
177
+ if resp.status == 200:
178
+ data = await resp.json()
179
+ self._token = data['auth']['client_token']
180
+ _vault_token = self._token
181
+ logger.info(
182
+ 'Authenticated with Vault using Kubernetes auth'
183
+ )
184
+ return self._token
185
+ else:
186
+ error = await resp.text()
187
+ logger.warning(
188
+ f'Kubernetes auth failed: {resp.status} - {error}'
189
+ )
190
+ except Exception as e:
191
+ logger.warning(f'Kubernetes auth error: {e}')
192
+
193
+ logger.warning('No Vault authentication available')
194
+ return None
195
+
196
+ async def _request(
197
+ self, method: str, path: str, data: Optional[Dict] = None
198
+ ) -> Optional[Dict]:
199
+ """Make authenticated request to Vault."""
200
+ token = await self._get_token()
201
+ if not token:
202
+ return None
203
+
204
+ session = await self._get_session()
205
+ headers = {'X-Vault-Token': token}
206
+
207
+ url = f'{self.addr}/v1/{path}'
208
+
209
+ try:
210
+ async with session.request(
211
+ method, url, headers=headers, json=data
212
+ ) as resp:
213
+ if resp.status == 200:
214
+ return await resp.json()
215
+ elif resp.status == 204:
216
+ return {}
217
+ elif resp.status == 404:
218
+ return None
219
+ else:
220
+ error = await resp.text()
221
+ logger.error(
222
+ f'Vault request failed: {resp.status} - {error}'
223
+ )
224
+ return None
225
+ except Exception as e:
226
+ logger.error(f'Vault request error: {e}')
227
+ return None
228
+
229
+ async def read_secret(self, path: str) -> Optional[Dict[str, Any]]:
230
+ """Read a secret from Vault KV v2."""
231
+ result = await self._request('GET', f'{VAULT_MOUNT_PATH}/data/{path}')
232
+ if result and 'data' in result and 'data' in result['data']:
233
+ return result['data']['data']
234
+ return None
235
+
236
+ async def write_secret(self, path: str, data: Dict[str, Any]) -> bool:
237
+ """Write a secret to Vault KV v2."""
238
+ result = await self._request(
239
+ 'POST', f'{VAULT_MOUNT_PATH}/data/{path}', {'data': data}
240
+ )
241
+ return result is not None
242
+
243
+ async def delete_secret(self, path: str) -> bool:
244
+ """Delete a secret from Vault KV v2."""
245
+ result = await self._request(
246
+ 'DELETE', f'{VAULT_MOUNT_PATH}/data/{path}'
247
+ )
248
+ return result is not None
249
+
250
+ async def list_secrets(self, path: str) -> List[str]:
251
+ """List secrets at a path in Vault KV v2."""
252
+ result = await self._request(
253
+ 'LIST', f'{VAULT_MOUNT_PATH}/metadata/{path}'
254
+ )
255
+ if result and 'data' in result and 'keys' in result['data']:
256
+ return result['data']['keys']
257
+ return []
258
+
259
+
260
+ # Singleton client instance
261
+ _client: Optional[VaultClient] = None
262
+
263
+
264
+ def get_vault_client() -> VaultClient:
265
+ """Get the singleton Vault client instance."""
266
+ global _client
267
+ if _client is None:
268
+ _client = VaultClient()
269
+ return _client
270
+
271
+
272
+ # =============================================================================
273
+ # Per-User API Key Management Functions
274
+ # =============================================================================
275
+
276
+
277
+ def _user_api_keys_path(user_id: str) -> str:
278
+ """Get the Vault path for a user's API keys."""
279
+ # Sanitize user_id for use in path
280
+ safe_user_id = user_id.replace('/', '_').replace('\\', '_')
281
+ return f'{VAULT_API_KEYS_BASE_PATH}/{safe_user_id}/api-keys'
282
+
283
+
284
+ async def get_user_api_key(
285
+ user_id: str, provider_id: str
286
+ ) -> Optional[Dict[str, Any]]:
287
+ """Get an API key for a specific provider for a user."""
288
+ client = get_vault_client()
289
+ path = f'{_user_api_keys_path(user_id)}/{provider_id}'
290
+ return await client.read_secret(path)
291
+
292
+
293
+ async def set_user_api_key(
294
+ user_id: str,
295
+ provider_id: str,
296
+ api_key: str,
297
+ provider_name: Optional[str] = None,
298
+ base_url: Optional[str] = None,
299
+ metadata: Optional[Dict] = None,
300
+ ) -> bool:
301
+ """Store an API key for a provider for a specific user."""
302
+ client = get_vault_client()
303
+ path = f'{_user_api_keys_path(user_id)}/{provider_id}'
304
+
305
+ # Get provider info
306
+ provider_info = KNOWN_PROVIDERS.get(provider_id, {})
307
+
308
+ data = {
309
+ 'api_key': api_key,
310
+ 'provider_id': provider_id,
311
+ 'provider_name': provider_name
312
+ or provider_info.get('name', provider_id),
313
+ 'user_id': user_id,
314
+ 'updated_at': datetime.now(timezone.utc).isoformat(),
315
+ }
316
+
317
+ # Include base_url if provided or from known providers
318
+ if base_url:
319
+ data['base_url'] = base_url
320
+ elif 'base_url' in provider_info:
321
+ data['base_url'] = provider_info['base_url']
322
+
323
+ if metadata:
324
+ data['metadata'] = metadata
325
+
326
+ return await client.write_secret(path, data)
327
+
328
+
329
+ async def delete_user_api_key(user_id: str, provider_id: str) -> bool:
330
+ """Delete an API key for a user."""
331
+ client = get_vault_client()
332
+ path = f'{_user_api_keys_path(user_id)}/{provider_id}'
333
+ return await client.delete_secret(path)
334
+
335
+
336
+ async def list_user_api_keys(user_id: str) -> List[str]:
337
+ """List all configured provider IDs for a user."""
338
+ client = get_vault_client()
339
+ path = _user_api_keys_path(user_id)
340
+ keys = await client.list_secrets(path)
341
+ # Remove trailing slashes from list output
342
+ return [k.rstrip('/') for k in keys]
343
+
344
+
345
+ async def get_all_user_api_keys(user_id: str) -> Dict[str, Dict[str, Any]]:
346
+ """Get all API keys for a user."""
347
+ provider_ids = await list_user_api_keys(user_id)
348
+
349
+ keys = {}
350
+ for pid in provider_ids:
351
+ key_data = await get_user_api_key(user_id, pid)
352
+ if key_data:
353
+ keys[pid] = key_data
354
+
355
+ return keys
356
+
357
+
358
+ async def get_user_opencode_auth_json(
359
+ user_id: str,
360
+ ) -> Dict[str, Dict[str, str]]:
361
+ """Get all API keys for a user formatted as OpenCode auth.json structure."""
362
+ all_keys = await get_all_user_api_keys(user_id)
363
+
364
+ auth_json = {}
365
+ for pid, data in all_keys.items():
366
+ auth_json[pid] = {
367
+ 'type': 'api',
368
+ 'key': data.get('api_key', ''),
369
+ }
370
+
371
+ return auth_json
372
+
373
+
374
+ async def get_user_opencode_provider_config(
375
+ user_id: str,
376
+ ) -> Dict[str, Dict[str, Any]]:
377
+ """Get provider configuration for a user's custom providers."""
378
+ all_keys = await get_all_user_api_keys(user_id)
379
+
380
+ provider_config = {}
381
+ for pid, data in all_keys.items():
382
+ # Only include custom providers that need special configuration
383
+ if pid in KNOWN_PROVIDERS:
384
+ provider_info = KNOWN_PROVIDERS[pid]
385
+ if 'base_url' in provider_info or 'base_url' in data:
386
+ provider_config[pid] = {
387
+ 'npm': provider_info.get(
388
+ 'npm', '@ai-sdk/openai-compatible'
389
+ ),
390
+ 'name': provider_info.get('name', pid),
391
+ 'options': {
392
+ 'baseURL': data.get('base_url')
393
+ or provider_info.get('base_url'),
394
+ },
395
+ }
396
+ if 'models' in provider_info:
397
+ provider_config[pid]['models'] = provider_info['models']
398
+
399
+ return provider_config
400
+
401
+
402
+ # =============================================================================
403
+ # Worker Sync Functions
404
+ # =============================================================================
405
+
406
+
407
+ async def get_worker_sync_data(user_id: str) -> Dict[str, Any]:
408
+ """
409
+ Get all data needed for a worker to sync a user's API keys.
410
+
411
+ Returns:
412
+ Dict containing:
413
+ - auth: OpenCode auth.json format
414
+ - providers: Custom provider configurations for opencode.json
415
+ - updated_at: Timestamp of last update
416
+ """
417
+ auth_json = await get_user_opencode_auth_json(user_id)
418
+ provider_config = await get_user_opencode_provider_config(user_id)
419
+
420
+ return {
421
+ 'user_id': user_id,
422
+ 'auth': auth_json,
423
+ 'providers': provider_config,
424
+ 'updated_at': datetime.now(timezone.utc).isoformat(),
425
+ }
426
+
427
+
428
+ # =============================================================================
429
+ # Health Check
430
+ # =============================================================================
431
+
432
+
433
+ async def check_vault_connection() -> Dict[str, Any]:
434
+ """Check Vault connectivity and authentication status."""
435
+ client = get_vault_client()
436
+
437
+ try:
438
+ session = await client._get_session()
439
+
440
+ # Check if Vault is reachable
441
+ try:
442
+ async with session.get(f'{client.addr}/v1/sys/health') as resp:
443
+ health = await resp.json() if resp.status == 200 else {}
444
+ connected = resp.status in (200, 429, 472, 473, 501, 503)
445
+ except Exception:
446
+ health = {}
447
+ connected = False
448
+
449
+ # Check if we can authenticate
450
+ token = await client._get_token()
451
+
452
+ return {
453
+ 'connected': connected,
454
+ 'authenticated': token is not None,
455
+ 'vault_addr': client.addr,
456
+ 'health': health,
457
+ }
458
+ except Exception as e:
459
+ return {
460
+ 'connected': False,
461
+ 'authenticated': False,
462
+ 'vault_addr': client.addr,
463
+ 'error': str(e),
464
+ }
465
+
466
+
467
+ # =============================================================================
468
+ # Test API Key Function
469
+ # =============================================================================
470
+
471
+
472
+ async def test_api_key(provider_id: str, api_key: str) -> Dict[str, Any]:
473
+ """Test an API key by making a simple request to the provider."""
474
+
475
+ # Define test endpoints for known providers
476
+ test_configs = {
477
+ 'anthropic': {
478
+ 'url': 'https://api.anthropic.com/v1/messages',
479
+ 'headers': {
480
+ 'x-api-key': api_key,
481
+ 'anthropic-version': '2023-06-01',
482
+ 'content-type': 'application/json',
483
+ },
484
+ 'body': {
485
+ 'model': 'claude-3-haiku-20240307',
486
+ 'max_tokens': 10,
487
+ 'messages': [{'role': 'user', 'content': 'Hi'}],
488
+ },
489
+ },
490
+ 'minimax-m2': {
491
+ 'url': 'https://api.minimax.io/anthropic/v1/messages',
492
+ 'headers': {
493
+ 'x-api-key': api_key,
494
+ 'content-type': 'application/json',
495
+ },
496
+ 'body': {
497
+ 'model': 'MiniMax-M2',
498
+ 'max_tokens': 10,
499
+ 'messages': [{'role': 'user', 'content': 'Hi'}],
500
+ },
501
+ },
502
+ 'minimax': {
503
+ 'url': 'https://api.minimax.io/anthropic/v1/messages',
504
+ 'headers': {
505
+ 'x-api-key': api_key,
506
+ 'content-type': 'application/json',
507
+ },
508
+ 'body': {
509
+ 'model': 'MiniMax-M2',
510
+ 'max_tokens': 10,
511
+ 'messages': [{'role': 'user', 'content': 'Hi'}],
512
+ },
513
+ },
514
+ 'openai': {
515
+ 'url': 'https://api.openai.com/v1/chat/completions',
516
+ 'headers': {
517
+ 'Authorization': f'Bearer {api_key}',
518
+ 'content-type': 'application/json',
519
+ },
520
+ 'body': {
521
+ 'model': 'gpt-3.5-turbo',
522
+ 'max_tokens': 10,
523
+ 'messages': [{'role': 'user', 'content': 'Hi'}],
524
+ },
525
+ },
526
+ 'deepseek': {
527
+ 'url': 'https://api.deepseek.com/v1/chat/completions',
528
+ 'headers': {
529
+ 'Authorization': f'Bearer {api_key}',
530
+ 'content-type': 'application/json',
531
+ },
532
+ 'body': {
533
+ 'model': 'deepseek-chat',
534
+ 'max_tokens': 10,
535
+ 'messages': [{'role': 'user', 'content': 'Hi'}],
536
+ },
537
+ },
538
+ }
539
+
540
+ if provider_id not in test_configs:
541
+ return {
542
+ 'success': True,
543
+ 'message': f'API key saved (no test available for {provider_id})',
544
+ 'tested': False,
545
+ }
546
+
547
+ config = test_configs[provider_id]
548
+
549
+ try:
550
+ async with aiohttp.ClientSession() as session:
551
+ async with session.post(
552
+ config['url'],
553
+ headers=config['headers'],
554
+ json=config['body'],
555
+ timeout=aiohttp.ClientTimeout(total=30),
556
+ ) as resp:
557
+ if resp.status == 200:
558
+ return {
559
+ 'success': True,
560
+ 'message': f'API key for {provider_id} is valid',
561
+ 'tested': True,
562
+ }
563
+ else:
564
+ error_text = await resp.text()
565
+ return {
566
+ 'success': False,
567
+ 'message': f'API key test failed: {resp.status}',
568
+ 'error': error_text[:200],
569
+ 'tested': True,
570
+ }
571
+ except Exception as e:
572
+ return {
573
+ 'success': False,
574
+ 'message': f'API key test failed: {str(e)}',
575
+ 'tested': True,
576
+ }