kairo-code 0.1.0__py3-none-any.whl → 0.2.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.
Files changed (50) hide show
  1. kairo/backend/api/agents.py +337 -16
  2. kairo/backend/app.py +84 -4
  3. kairo/backend/config.py +4 -2
  4. kairo/backend/models/agent.py +216 -2
  5. kairo/backend/models/api_key.py +4 -1
  6. kairo/backend/models/task.py +31 -0
  7. kairo/backend/models/user_provider_key.py +26 -0
  8. kairo/backend/schemas/agent.py +249 -2
  9. kairo/backend/schemas/api_key.py +3 -0
  10. kairo/backend/services/agent/__init__.py +52 -0
  11. kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
  12. kairo/backend/services/agent/agent_alerts_service.py +201 -0
  13. kairo/backend/services/agent/agent_commands_service.py +142 -0
  14. kairo/backend/services/agent/agent_crud_service.py +150 -0
  15. kairo/backend/services/agent/agent_events_service.py +103 -0
  16. kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
  17. kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
  18. kairo/backend/services/agent/agent_metrics_service.py +259 -0
  19. kairo/backend/services/agent/agent_service.py +315 -0
  20. kairo/backend/services/agent/agent_setup_service.py +180 -0
  21. kairo/backend/services/agent/constants.py +28 -0
  22. kairo/backend/services/agent_service.py +18 -102
  23. kairo/backend/services/api_key_service.py +23 -3
  24. kairo/backend/services/byok_service.py +204 -0
  25. kairo/backend/services/chat_service.py +398 -63
  26. kairo/backend/services/deep_search_service.py +159 -0
  27. kairo/backend/services/email_service.py +418 -19
  28. kairo/backend/services/few_shot_service.py +223 -0
  29. kairo/backend/services/post_processor.py +261 -0
  30. kairo/backend/services/rag_service.py +150 -0
  31. kairo/backend/services/task_service.py +119 -0
  32. kairo/backend/tests/__init__.py +1 -0
  33. kairo/backend/tests/e2e/__init__.py +1 -0
  34. kairo/backend/tests/e2e/agents/__init__.py +1 -0
  35. kairo/backend/tests/e2e/agents/conftest.py +389 -0
  36. kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
  37. kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
  38. kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
  39. kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
  40. kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
  41. kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
  42. kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
  43. kairo/migrations/versions/010_agent_dashboard.py +246 -0
  44. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
  45. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
  46. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
  47. kairo_migrations/env.py +92 -0
  48. kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
  49. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
  50. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,204 @@
1
+ """BYOK (Bring Your Own Key) service for Max plan users.
2
+
3
+ Handles encrypted storage and proxied inference using user-provided
4
+ OpenAI/Anthropic API keys.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import secrets
10
+
11
+ import httpx
12
+ from cryptography.fernet import Fernet
13
+
14
+ from sqlalchemy import select
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from backend.models.user_provider_key import UserProviderKey
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Derive encryption key from JWT secret or dedicated env var.
22
+ # In production, use a dedicated KMS key.
23
+ _ENCRYPTION_KEY: bytes | None = None
24
+
25
+
26
+ def _get_fernet() -> Fernet:
27
+ global _ENCRYPTION_KEY
28
+ if _ENCRYPTION_KEY is None:
29
+ key_str = os.environ.get("KAIRO_BYOK_ENCRYPTION_KEY", "")
30
+ if not key_str:
31
+ # Derive from JWT secret (acceptable for single-instance deploy)
32
+ from backend.config import settings
33
+ import hashlib
34
+ import base64
35
+ raw = hashlib.sha256(settings.JWT_SECRET_KEY.encode()).digest()
36
+ _ENCRYPTION_KEY = base64.urlsafe_b64encode(raw)
37
+ else:
38
+ _ENCRYPTION_KEY = key_str.encode()
39
+ return Fernet(_ENCRYPTION_KEY)
40
+
41
+
42
+ def encrypt_key(plaintext_key: str) -> str:
43
+ """Encrypt an API key for storage."""
44
+ f = _get_fernet()
45
+ return f.encrypt(plaintext_key.encode()).decode()
46
+
47
+
48
+ def decrypt_key(encrypted_key: str) -> str:
49
+ """Decrypt a stored API key."""
50
+ f = _get_fernet()
51
+ return f.decrypt(encrypted_key.encode()).decode()
52
+
53
+
54
+ # Allowed upstream endpoints per provider
55
+ _PROVIDER_ENDPOINTS = {
56
+ "openai": {
57
+ "base_url": "https://api.openai.com",
58
+ "chat_path": "/v1/chat/completions",
59
+ "models_path": "/v1/models",
60
+ },
61
+ "anthropic": {
62
+ "base_url": "https://api.anthropic.com",
63
+ "chat_path": "/v1/messages",
64
+ "models_path": None,
65
+ },
66
+ }
67
+
68
+
69
+ class BYOKService:
70
+ def __init__(self, db: AsyncSession):
71
+ self.db = db
72
+
73
+ async def store_key(
74
+ self, user_id: str, provider: str, api_key: str, label: str | None = None,
75
+ ) -> UserProviderKey:
76
+ """Store a user's provider API key (encrypted)."""
77
+ if provider not in _PROVIDER_ENDPOINTS:
78
+ raise ValueError(f"Unsupported provider: {provider}")
79
+
80
+ # Validate the key works by calling the provider
81
+ await self._validate_key(provider, api_key)
82
+
83
+ encrypted = encrypt_key(api_key)
84
+ suffix = api_key[-4:]
85
+
86
+ record = UserProviderKey(
87
+ user_id=user_id,
88
+ provider=provider,
89
+ encrypted_key=encrypted,
90
+ key_suffix=suffix,
91
+ label=label,
92
+ )
93
+ self.db.add(record)
94
+ await self.db.commit()
95
+ await self.db.refresh(record)
96
+ logger.info("BYOK key stored: user=%s provider=%s id=%s", user_id, provider, record.id)
97
+ return record
98
+
99
+ async def list_keys(self, user_id: str) -> list[UserProviderKey]:
100
+ """List user's stored provider keys (without decrypting)."""
101
+ stmt = (
102
+ select(UserProviderKey)
103
+ .where(UserProviderKey.user_id == user_id)
104
+ .order_by(UserProviderKey.created_at.desc())
105
+ )
106
+ result = await self.db.execute(stmt)
107
+ return list(result.scalars().all())
108
+
109
+ async def delete_key(self, user_id: str, key_id: str) -> bool:
110
+ """Delete a stored provider key."""
111
+ stmt = select(UserProviderKey).where(
112
+ UserProviderKey.id == key_id, UserProviderKey.user_id == user_id
113
+ )
114
+ result = await self.db.execute(stmt)
115
+ record = result.scalar_one_or_none()
116
+ if not record:
117
+ return False
118
+ await self.db.delete(record)
119
+ await self.db.commit()
120
+ logger.info("BYOK key deleted: id=%s user=%s", key_id, user_id)
121
+ return True
122
+
123
+ async def proxy_chat(
124
+ self, user_id: str, key_id: str, request_body: dict,
125
+ ) -> dict:
126
+ """Proxy a chat completion request using the user's stored key."""
127
+ stmt = select(UserProviderKey).where(
128
+ UserProviderKey.id == key_id, UserProviderKey.user_id == user_id
129
+ )
130
+ result = await self.db.execute(stmt)
131
+ record = result.scalar_one_or_none()
132
+ if not record:
133
+ raise ValueError("Provider key not found")
134
+
135
+ provider = record.provider
136
+ config = _PROVIDER_ENDPOINTS[provider]
137
+ api_key = decrypt_key(record.encrypted_key)
138
+
139
+ headers = {"Content-Type": "application/json"}
140
+ if provider == "openai":
141
+ headers["Authorization"] = f"Bearer {api_key}"
142
+ elif provider == "anthropic":
143
+ headers["x-api-key"] = api_key
144
+ headers["anthropic-version"] = "2023-06-01"
145
+
146
+ url = f"{config['base_url']}{config['chat_path']}"
147
+
148
+ try:
149
+ async with httpx.AsyncClient(timeout=120.0) as client:
150
+ resp = await client.post(url, json=request_body, headers=headers)
151
+ resp.raise_for_status()
152
+ return resp.json()
153
+ except httpx.HTTPStatusError as e:
154
+ # Sanitize error - never leak the API key
155
+ status = e.response.status_code
156
+ if status == 401:
157
+ detail = "Provider returned 401 Unauthorized. Check your API key."
158
+ elif status == 429:
159
+ detail = "Provider rate limit exceeded. Try again later."
160
+ else:
161
+ detail = f"Provider returned HTTP {status}."
162
+ logger.error(
163
+ "BYOK proxy error: user=%s provider=%s status=%d",
164
+ user_id, provider, status,
165
+ )
166
+ raise ValueError(detail)
167
+ except httpx.RequestError:
168
+ logger.error("BYOK proxy connection error: user=%s provider=%s", user_id, provider)
169
+ raise ValueError("Could not connect to provider. Try again later.")
170
+
171
+ async def _validate_key(self, provider: str, api_key: str) -> None:
172
+ """Validate a provider key by making a lightweight API call."""
173
+ config = _PROVIDER_ENDPOINTS[provider]
174
+ headers = {}
175
+
176
+ if provider == "openai":
177
+ headers["Authorization"] = f"Bearer {api_key}"
178
+ url = f"{config['base_url']}{config['models_path']}"
179
+ elif provider == "anthropic":
180
+ headers["x-api-key"] = api_key
181
+ headers["anthropic-version"] = "2023-06-01"
182
+ # Anthropic has no /models endpoint; send a minimal message
183
+ url = f"{config['base_url']}{config['chat_path']}"
184
+ else:
185
+ return
186
+
187
+ try:
188
+ async with httpx.AsyncClient(timeout=10.0) as client:
189
+ if provider == "openai":
190
+ resp = await client.get(url, headers=headers)
191
+ else:
192
+ # Minimal Anthropic request to validate the key
193
+ resp = await client.post(
194
+ url, json={
195
+ "model": "claude-3-haiku-20240307",
196
+ "max_tokens": 1,
197
+ "messages": [{"role": "user", "content": "hi"}],
198
+ }, headers=headers,
199
+ )
200
+ if resp.status_code == 401:
201
+ raise ValueError("Invalid API key. Provider returned 401.")
202
+ # Any other status (200, 429, etc.) means the key is valid
203
+ except httpx.RequestError:
204
+ raise ValueError("Could not reach provider to validate key. Try again.")