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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {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.")
|