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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- 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
|
+
}
|