kairo-code 0.1.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.
- image-service/main.py +178 -0
- infra/chat/app/main.py +84 -0
- kairo/backend/__init__.py +0 -0
- kairo/backend/api/__init__.py +0 -0
- kairo/backend/api/admin/__init__.py +23 -0
- kairo/backend/api/admin/audit.py +54 -0
- kairo/backend/api/admin/content.py +142 -0
- kairo/backend/api/admin/incidents.py +148 -0
- kairo/backend/api/admin/stats.py +125 -0
- kairo/backend/api/admin/system.py +87 -0
- kairo/backend/api/admin/users.py +279 -0
- kairo/backend/api/agents.py +94 -0
- kairo/backend/api/api_keys.py +85 -0
- kairo/backend/api/auth.py +116 -0
- kairo/backend/api/billing.py +41 -0
- kairo/backend/api/chat.py +72 -0
- kairo/backend/api/conversations.py +125 -0
- kairo/backend/api/device_auth.py +100 -0
- kairo/backend/api/files.py +83 -0
- kairo/backend/api/health.py +36 -0
- kairo/backend/api/images.py +80 -0
- kairo/backend/api/openai_compat.py +225 -0
- kairo/backend/api/projects.py +102 -0
- kairo/backend/api/usage.py +32 -0
- kairo/backend/api/webhooks.py +79 -0
- kairo/backend/app.py +297 -0
- kairo/backend/config.py +179 -0
- kairo/backend/core/__init__.py +0 -0
- kairo/backend/core/admin_auth.py +24 -0
- kairo/backend/core/api_key_auth.py +55 -0
- kairo/backend/core/database.py +28 -0
- kairo/backend/core/dependencies.py +70 -0
- kairo/backend/core/logging.py +23 -0
- kairo/backend/core/rate_limit.py +73 -0
- kairo/backend/core/security.py +29 -0
- kairo/backend/models/__init__.py +19 -0
- kairo/backend/models/agent.py +30 -0
- kairo/backend/models/api_key.py +25 -0
- kairo/backend/models/api_usage.py +29 -0
- kairo/backend/models/audit_log.py +26 -0
- kairo/backend/models/conversation.py +48 -0
- kairo/backend/models/device_code.py +30 -0
- kairo/backend/models/feature_flag.py +21 -0
- kairo/backend/models/image_generation.py +24 -0
- kairo/backend/models/incident.py +28 -0
- kairo/backend/models/project.py +28 -0
- kairo/backend/models/uptime_record.py +24 -0
- kairo/backend/models/usage.py +24 -0
- kairo/backend/models/user.py +49 -0
- kairo/backend/schemas/__init__.py +0 -0
- kairo/backend/schemas/admin/__init__.py +0 -0
- kairo/backend/schemas/admin/audit.py +28 -0
- kairo/backend/schemas/admin/content.py +53 -0
- kairo/backend/schemas/admin/stats.py +77 -0
- kairo/backend/schemas/admin/system.py +44 -0
- kairo/backend/schemas/admin/users.py +48 -0
- kairo/backend/schemas/agent.py +42 -0
- kairo/backend/schemas/api_key.py +30 -0
- kairo/backend/schemas/auth.py +57 -0
- kairo/backend/schemas/chat.py +26 -0
- kairo/backend/schemas/conversation.py +39 -0
- kairo/backend/schemas/device_auth.py +40 -0
- kairo/backend/schemas/image.py +15 -0
- kairo/backend/schemas/openai_compat.py +76 -0
- kairo/backend/schemas/project.py +21 -0
- kairo/backend/schemas/status.py +81 -0
- kairo/backend/schemas/usage.py +15 -0
- kairo/backend/services/__init__.py +0 -0
- kairo/backend/services/admin/__init__.py +0 -0
- kairo/backend/services/admin/audit_service.py +78 -0
- kairo/backend/services/admin/content_service.py +119 -0
- kairo/backend/services/admin/incident_service.py +94 -0
- kairo/backend/services/admin/stats_service.py +281 -0
- kairo/backend/services/admin/system_service.py +126 -0
- kairo/backend/services/admin/user_service.py +157 -0
- kairo/backend/services/agent_service.py +107 -0
- kairo/backend/services/api_key_service.py +66 -0
- kairo/backend/services/api_usage_service.py +126 -0
- kairo/backend/services/auth_service.py +101 -0
- kairo/backend/services/chat_service.py +501 -0
- kairo/backend/services/conversation_service.py +264 -0
- kairo/backend/services/device_auth_service.py +193 -0
- kairo/backend/services/email_service.py +55 -0
- kairo/backend/services/image_service.py +181 -0
- kairo/backend/services/llm_service.py +186 -0
- kairo/backend/services/project_service.py +109 -0
- kairo/backend/services/status_service.py +167 -0
- kairo/backend/services/stripe_service.py +78 -0
- kairo/backend/services/usage_service.py +150 -0
- kairo/backend/services/web_search_service.py +96 -0
- kairo/migrations/env.py +60 -0
- kairo/migrations/versions/001_initial.py +55 -0
- kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
- kairo/migrations/versions/003_username_to_email.py +21 -0
- kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
- kairo/migrations/versions/005_add_projects.py +52 -0
- kairo/migrations/versions/006_add_image_generation.py +63 -0
- kairo/migrations/versions/007_add_admin_portal.py +107 -0
- kairo/migrations/versions/008_add_device_code_auth.py +76 -0
- kairo/migrations/versions/009_add_status_page.py +65 -0
- kairo/tools/extract_claude_data.py +465 -0
- kairo/tools/filter_claude_data.py +303 -0
- kairo/tools/generate_curated_data.py +157 -0
- kairo/tools/mix_training_data.py +295 -0
- kairo_code/__init__.py +3 -0
- kairo_code/agents/__init__.py +25 -0
- kairo_code/agents/architect.py +98 -0
- kairo_code/agents/audit.py +100 -0
- kairo_code/agents/base.py +463 -0
- kairo_code/agents/coder.py +155 -0
- kairo_code/agents/database.py +77 -0
- kairo_code/agents/docs.py +88 -0
- kairo_code/agents/explorer.py +62 -0
- kairo_code/agents/guardian.py +80 -0
- kairo_code/agents/planner.py +66 -0
- kairo_code/agents/reviewer.py +91 -0
- kairo_code/agents/security.py +94 -0
- kairo_code/agents/terraform.py +88 -0
- kairo_code/agents/testing.py +97 -0
- kairo_code/agents/uiux.py +88 -0
- kairo_code/auth.py +232 -0
- kairo_code/config.py +172 -0
- kairo_code/conversation.py +173 -0
- kairo_code/heartbeat.py +63 -0
- kairo_code/llm.py +291 -0
- kairo_code/logging_config.py +156 -0
- kairo_code/main.py +818 -0
- kairo_code/router.py +217 -0
- kairo_code/sandbox.py +248 -0
- kairo_code/settings.py +183 -0
- kairo_code/tools/__init__.py +51 -0
- kairo_code/tools/analysis.py +509 -0
- kairo_code/tools/base.py +417 -0
- kairo_code/tools/code.py +58 -0
- kairo_code/tools/definitions.py +617 -0
- kairo_code/tools/files.py +315 -0
- kairo_code/tools/review.py +390 -0
- kairo_code/tools/search.py +185 -0
- kairo_code/ui.py +418 -0
- kairo_code-0.1.0.dist-info/METADATA +13 -0
- kairo_code-0.1.0.dist-info/RECORD +144 -0
- kairo_code-0.1.0.dist-info/WHEEL +5 -0
- kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
- kairo_code-0.1.0.dist-info/top_level.txt +4 -0
kairo/backend/app.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
14
|
+
|
|
15
|
+
from backend.core.database import init_db
|
|
16
|
+
from backend.core.logging import setup_logging
|
|
17
|
+
from backend.api.auth import router as auth_router
|
|
18
|
+
from backend.api.billing import router as billing_router
|
|
19
|
+
from backend.api.chat import router as chat_router
|
|
20
|
+
from backend.api.conversations import router as conversations_router
|
|
21
|
+
from backend.api.health import router as health_router
|
|
22
|
+
from backend.api.projects import router as projects_router
|
|
23
|
+
from backend.api.usage import router as usage_router
|
|
24
|
+
from backend.api.webhooks import router as webhooks_router
|
|
25
|
+
from backend.api.api_keys import router as api_keys_router
|
|
26
|
+
from backend.api.agents import router as agents_router
|
|
27
|
+
from backend.api.images import router as images_router
|
|
28
|
+
from backend.api.openai_compat import router as openai_compat_router
|
|
29
|
+
from backend.api.admin import router as admin_router
|
|
30
|
+
from backend.api.device_auth import router as device_auth_router
|
|
31
|
+
from backend.api.files import router as files_router
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Regex to redact passwords from DB URLs
|
|
36
|
+
_DB_URL_RE = re.compile(r"(://\w+:)[^@]+(@)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _redact_url(url: str) -> str:
|
|
40
|
+
return _DB_URL_RE.sub(r"\1***\2", url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RequestLoggingMiddleware:
|
|
44
|
+
"""Pure ASGI middleware — does not wrap StreamingResponse body."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, app: ASGIApp):
|
|
47
|
+
self.app = app
|
|
48
|
+
|
|
49
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
50
|
+
if scope["type"] != "http":
|
|
51
|
+
await self.app(scope, receive, send)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
start = time.perf_counter()
|
|
55
|
+
status_code = 0
|
|
56
|
+
|
|
57
|
+
async def send_wrapper(message):
|
|
58
|
+
nonlocal status_code
|
|
59
|
+
if message["type"] == "http.response.start":
|
|
60
|
+
status_code = message["status"]
|
|
61
|
+
await send(message)
|
|
62
|
+
|
|
63
|
+
path = scope.get("path", "")
|
|
64
|
+
await self.app(scope, receive, send_wrapper)
|
|
65
|
+
|
|
66
|
+
if path.startswith("/api"):
|
|
67
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
68
|
+
method = scope.get("method", "?")
|
|
69
|
+
logger.info(
|
|
70
|
+
"%s %s → %d (%.0fms)", method, path, status_code, elapsed_ms,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def lifespan(app: FastAPI):
|
|
76
|
+
from backend.config import settings
|
|
77
|
+
|
|
78
|
+
setup_logging(settings.LOG_LEVEL)
|
|
79
|
+
|
|
80
|
+
# Ensure data directory exists
|
|
81
|
+
Path(settings.DATA_DIR).mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
logger.info("Kairo starting up")
|
|
84
|
+
logger.info("Data dir: %s", Path(settings.DATA_DIR).resolve())
|
|
85
|
+
logger.info("Database: %s", _redact_url(settings.DATABASE_URL))
|
|
86
|
+
logger.info("vLLM: %s", settings.VLLM_BASE_URL)
|
|
87
|
+
if settings.VLLM_LITE_BASE_URL:
|
|
88
|
+
logger.info("vLLM Lite: %s", settings.VLLM_LITE_BASE_URL)
|
|
89
|
+
await init_db()
|
|
90
|
+
logger.info("Database initialized")
|
|
91
|
+
|
|
92
|
+
# Shared LLM client
|
|
93
|
+
from backend.services.llm_service import LLMService
|
|
94
|
+
app.state.llm_service = LLMService()
|
|
95
|
+
logger.info("LLM client initialized")
|
|
96
|
+
|
|
97
|
+
# Background health checker for primary and lite vLLM
|
|
98
|
+
async def _health_check_loop():
|
|
99
|
+
llm: LLMService = app.state.llm_service
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
await llm.check_primary_health()
|
|
103
|
+
await llm.check_lite_health()
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning("Health check loop error: %s", e)
|
|
106
|
+
await asyncio.sleep(30)
|
|
107
|
+
|
|
108
|
+
health_task = asyncio.create_task(_health_check_loop())
|
|
109
|
+
logger.info("vLLM health check started (primary + lite, 30s interval)")
|
|
110
|
+
|
|
111
|
+
# Background task to mark stale agents as offline
|
|
112
|
+
async def _agent_staleness_loop():
|
|
113
|
+
from backend.core.database import async_session
|
|
114
|
+
from backend.services.agent_service import AgentService
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
async with async_session() as db:
|
|
118
|
+
svc = AgentService(db)
|
|
119
|
+
await svc.mark_stale_agents_offline(settings.AGENT_OFFLINE_THRESHOLD_SECONDS)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.warning("Agent staleness check error: %s", e)
|
|
122
|
+
await asyncio.sleep(60)
|
|
123
|
+
|
|
124
|
+
agent_task = asyncio.create_task(_agent_staleness_loop())
|
|
125
|
+
logger.info("Agent staleness checker started (60s interval)")
|
|
126
|
+
|
|
127
|
+
# Background task to clean up expired device codes
|
|
128
|
+
async def _device_code_cleanup_loop():
|
|
129
|
+
from backend.core.database import async_session
|
|
130
|
+
from backend.services.device_auth_service import DeviceAuthService
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
async with async_session() as db:
|
|
134
|
+
svc = DeviceAuthService(db)
|
|
135
|
+
await svc.cleanup_expired()
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning("Device code cleanup error: %s", e)
|
|
138
|
+
await asyncio.sleep(300) # every 5 minutes
|
|
139
|
+
|
|
140
|
+
device_cleanup_task = asyncio.create_task(_device_code_cleanup_loop())
|
|
141
|
+
logger.info("Device code cleanup started (300s interval)")
|
|
142
|
+
|
|
143
|
+
# Background task to record uptime snapshots every hour
|
|
144
|
+
async def _uptime_recording_loop():
|
|
145
|
+
import uuid
|
|
146
|
+
from datetime import date as date_type, datetime as dt, UTC as utc_tz
|
|
147
|
+
from sqlalchemy import select
|
|
148
|
+
from backend.core.database import async_session as uptime_session
|
|
149
|
+
from backend.models.uptime_record import UptimeRecord
|
|
150
|
+
|
|
151
|
+
# Wait 60s on startup before first snapshot
|
|
152
|
+
await asyncio.sleep(60)
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
llm: LLMService = app.state.llm_service
|
|
157
|
+
today = date_type.today()
|
|
158
|
+
|
|
159
|
+
# Determine current component health
|
|
160
|
+
components = {
|
|
161
|
+
"API": True, # If this code is running, API is up
|
|
162
|
+
"Models": getattr(llm, "primary_healthy", False) or getattr(llm, "lite_healthy", False),
|
|
163
|
+
"Database": True, # If we can write records, DB is up
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Check image generation health
|
|
167
|
+
if settings.FEATURE_IMAGE_GEN_ENABLED and settings.FLUX_BASE_URL:
|
|
168
|
+
try:
|
|
169
|
+
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
170
|
+
resp = await hc.get(f"{settings.FLUX_BASE_URL}/health")
|
|
171
|
+
components["Image Generation"] = resp.status_code == 200
|
|
172
|
+
except Exception:
|
|
173
|
+
components["Image Generation"] = False
|
|
174
|
+
else:
|
|
175
|
+
components["Image Generation"] = False
|
|
176
|
+
|
|
177
|
+
async with uptime_session() as db:
|
|
178
|
+
for comp_name, is_healthy in components.items():
|
|
179
|
+
# Check if a record already exists for this component+date
|
|
180
|
+
stmt = (
|
|
181
|
+
select(UptimeRecord)
|
|
182
|
+
.where(UptimeRecord.component == comp_name, UptimeRecord.date == today)
|
|
183
|
+
)
|
|
184
|
+
result = await db.execute(stmt)
|
|
185
|
+
record = result.scalar_one_or_none()
|
|
186
|
+
|
|
187
|
+
if record:
|
|
188
|
+
# Update: running average of uptime checks
|
|
189
|
+
# Each check is either 100% (healthy) or 0% (down)
|
|
190
|
+
# We weight the new check into the existing average
|
|
191
|
+
check_value = 100.0 if is_healthy else 0.0
|
|
192
|
+
# Simple exponential moving average with ~24 samples/day
|
|
193
|
+
record.uptime_percent = round(
|
|
194
|
+
record.uptime_percent * 0.96 + check_value * 0.04, 2
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
# First record for this component+date
|
|
198
|
+
record = UptimeRecord(
|
|
199
|
+
id=str(uuid.uuid4()),
|
|
200
|
+
component=comp_name,
|
|
201
|
+
date=today,
|
|
202
|
+
uptime_percent=100.0 if is_healthy else 0.0,
|
|
203
|
+
incidents_count=0,
|
|
204
|
+
created_at=dt.now(utc_tz),
|
|
205
|
+
)
|
|
206
|
+
db.add(record)
|
|
207
|
+
|
|
208
|
+
await db.commit()
|
|
209
|
+
logger.debug("Uptime snapshot recorded for %s", today)
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning("Uptime recording loop error: %s", e)
|
|
213
|
+
|
|
214
|
+
await asyncio.sleep(3600) # Every hour
|
|
215
|
+
|
|
216
|
+
uptime_task = asyncio.create_task(_uptime_recording_loop())
|
|
217
|
+
logger.info("Uptime recording started (3600s interval)")
|
|
218
|
+
|
|
219
|
+
yield
|
|
220
|
+
|
|
221
|
+
health_task.cancel()
|
|
222
|
+
agent_task.cancel()
|
|
223
|
+
device_cleanup_task.cancel()
|
|
224
|
+
uptime_task.cancel()
|
|
225
|
+
await app.state.llm_service.close()
|
|
226
|
+
logger.info("Kairo shutting down")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def create_app() -> FastAPI:
|
|
230
|
+
from backend.config import settings
|
|
231
|
+
|
|
232
|
+
app = FastAPI(title="Kairo", version="0.1.0", lifespan=lifespan)
|
|
233
|
+
|
|
234
|
+
# CORS — configurable via env
|
|
235
|
+
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
|
236
|
+
app.add_middleware(
|
|
237
|
+
CORSMiddleware,
|
|
238
|
+
allow_origins=origins,
|
|
239
|
+
allow_credentials=True,
|
|
240
|
+
allow_methods=["*"],
|
|
241
|
+
allow_headers=["*"],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Pure ASGI logging middleware (safe for SSE streaming)
|
|
245
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
246
|
+
|
|
247
|
+
# Global error handler
|
|
248
|
+
@app.exception_handler(Exception)
|
|
249
|
+
async def global_exception_handler(request: Request, exc: Exception):
|
|
250
|
+
logger.error("Unhandled error on %s %s: %s", request.method, request.url.path, exc)
|
|
251
|
+
return JSONResponse(
|
|
252
|
+
status_code=500,
|
|
253
|
+
content={"detail": "Internal server error"},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# API routes
|
|
257
|
+
app.include_router(auth_router, prefix="/api")
|
|
258
|
+
app.include_router(billing_router, prefix="/api")
|
|
259
|
+
app.include_router(chat_router, prefix="/api")
|
|
260
|
+
app.include_router(conversations_router, prefix="/api/conversations")
|
|
261
|
+
app.include_router(projects_router, prefix="/api/projects")
|
|
262
|
+
app.include_router(health_router, prefix="/api")
|
|
263
|
+
app.include_router(usage_router, prefix="/api")
|
|
264
|
+
app.include_router(webhooks_router, prefix="/api")
|
|
265
|
+
app.include_router(api_keys_router, prefix="/api")
|
|
266
|
+
app.include_router(agents_router, prefix="/api")
|
|
267
|
+
app.include_router(images_router, prefix="/api")
|
|
268
|
+
app.include_router(openai_compat_router) # mounted at /v1, no /api prefix
|
|
269
|
+
app.include_router(admin_router, prefix="/api")
|
|
270
|
+
app.include_router(device_auth_router, prefix="/api")
|
|
271
|
+
app.include_router(files_router, prefix="/api")
|
|
272
|
+
|
|
273
|
+
# Serve static frontend build
|
|
274
|
+
static_dir = Path(__file__).parent / "static"
|
|
275
|
+
if static_dir.exists():
|
|
276
|
+
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
|
277
|
+
|
|
278
|
+
@app.get("/{full_path:path}")
|
|
279
|
+
async def serve_spa(full_path: str):
|
|
280
|
+
file_path = (static_dir / full_path).resolve()
|
|
281
|
+
# Path traversal guard
|
|
282
|
+
if not str(file_path).startswith(str(static_dir.resolve())):
|
|
283
|
+
return FileResponse(static_dir / "index.html")
|
|
284
|
+
if file_path.is_file():
|
|
285
|
+
return FileResponse(file_path)
|
|
286
|
+
return FileResponse(static_dir / "index.html")
|
|
287
|
+
|
|
288
|
+
return app
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
app = create_app()
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
import uvicorn
|
|
295
|
+
from backend.config import settings
|
|
296
|
+
|
|
297
|
+
uvicorn.run("backend.app:app", host=settings.HOST, port=settings.PORT, reload=True)
|
kairo/backend/config.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings
|
|
6
|
+
|
|
7
|
+
_logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _stable_jwt_secret(data_dir: str = "./data") -> str:
|
|
11
|
+
"""Return a stable JWT secret: env var > file > generate-and-persist."""
|
|
12
|
+
env_val = os.environ.get("KAIRO_JWT_SECRET_KEY")
|
|
13
|
+
if env_val:
|
|
14
|
+
return env_val
|
|
15
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
16
|
+
secret_path = os.path.join(data_dir, ".jwt_secret")
|
|
17
|
+
try:
|
|
18
|
+
with open(secret_path) as f:
|
|
19
|
+
return f.read().strip()
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
secret = secrets.token_urlsafe(32)
|
|
22
|
+
with open(secret_path, "w") as f:
|
|
23
|
+
f.write(secret)
|
|
24
|
+
os.chmod(secret_path, 0o600)
|
|
25
|
+
_logger.info("Generated new JWT secret at %s", secret_path)
|
|
26
|
+
return secret
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Settings(BaseSettings):
|
|
30
|
+
DATABASE_URL: str = "postgresql+asyncpg://kairo:kairo@localhost:5432/kairo"
|
|
31
|
+
VLLM_BASE_URL: str = "http://localhost:8000"
|
|
32
|
+
VLLM_LITE_BASE_URL: str = ""
|
|
33
|
+
VLLM_API_KEY: str = ""
|
|
34
|
+
HOST: str = "0.0.0.0"
|
|
35
|
+
PORT: int = 3000
|
|
36
|
+
LOG_LEVEL: str = "INFO"
|
|
37
|
+
DATA_DIR: str = "./data"
|
|
38
|
+
|
|
39
|
+
# Auth
|
|
40
|
+
JWT_SECRET_KEY: str = ""
|
|
41
|
+
JWT_EXPIRE_HOURS: int = 24
|
|
42
|
+
|
|
43
|
+
# Usage limits (Free tier defaults)
|
|
44
|
+
DEFAULT_DAILY_TOKEN_LIMIT: int = 100_000
|
|
45
|
+
DEFAULT_MONTHLY_TOKEN_LIMIT: int = 2_000_000
|
|
46
|
+
|
|
47
|
+
# Pro tier limits
|
|
48
|
+
PRO_DAILY_TOKEN_LIMIT: int = 1_000_000
|
|
49
|
+
PRO_MONTHLY_TOKEN_LIMIT: int = 30_000_000
|
|
50
|
+
|
|
51
|
+
# Max tier limits
|
|
52
|
+
MAX_DAILY_TOKEN_LIMIT: int = 5_000_000
|
|
53
|
+
MAX_MONTHLY_TOKEN_LIMIT: int = 150_000_000
|
|
54
|
+
|
|
55
|
+
# API token rates (per 1M tokens, in cents)
|
|
56
|
+
API_RATE_NYX_INPUT: int = 100 # $1.00 per 1M
|
|
57
|
+
API_RATE_NYX_OUTPUT: int = 300 # $3.00 per 1M
|
|
58
|
+
API_RATE_NYX_LITE_INPUT: int = 50 # $0.50 per 1M
|
|
59
|
+
API_RATE_NYX_LITE_OUTPUT: int = 150 # $1.50 per 1M
|
|
60
|
+
API_MAX_DISCOUNT_PERCENT: int = 50 # Max tier gets 50% off
|
|
61
|
+
|
|
62
|
+
# API tier limits (separate pool from chat)
|
|
63
|
+
API_DAILY_TOKEN_LIMIT: int = 500_000
|
|
64
|
+
API_MONTHLY_TOKEN_LIMIT: int = 10_000_000
|
|
65
|
+
API_PRO_DAILY_TOKEN_LIMIT: int = 5_000_000
|
|
66
|
+
API_PRO_MONTHLY_TOKEN_LIMIT: int = 100_000_000
|
|
67
|
+
|
|
68
|
+
# CLI usage limits (separate from chat/API)
|
|
69
|
+
CLI_DAILY_TOKEN_LIMIT: int = 10_000_000
|
|
70
|
+
CLI_MONTHLY_TOKEN_LIMIT: int = 300_000_000
|
|
71
|
+
|
|
72
|
+
# Image generation
|
|
73
|
+
FLUX_BASE_URL: str = ""
|
|
74
|
+
FLUX_API_KEY: str = ""
|
|
75
|
+
S3_IMAGES_BUCKET: str = ""
|
|
76
|
+
S3_IMAGES_REGION: str = "us-east-1"
|
|
77
|
+
|
|
78
|
+
# Image limits (per plan)
|
|
79
|
+
PRO_DAILY_IMAGE_LIMIT: int = 20
|
|
80
|
+
PRO_MONTHLY_IMAGE_LIMIT: int = 200
|
|
81
|
+
MAX_DAILY_IMAGE_LIMIT: int = 100
|
|
82
|
+
MAX_MONTHLY_IMAGE_LIMIT: int = 1000
|
|
83
|
+
|
|
84
|
+
# Feature flags
|
|
85
|
+
FEATURE_KAIRO_API_ENABLED: bool = False
|
|
86
|
+
FEATURE_KAIRO_AGENTS_ENABLED: bool = False
|
|
87
|
+
FEATURE_IMAGE_GEN_ENABLED: bool = False
|
|
88
|
+
|
|
89
|
+
# Admin
|
|
90
|
+
ADMIN_FEATURE_FLAG_ALLOWLIST: list[str] = [
|
|
91
|
+
"FEATURE_KAIRO_API_ENABLED",
|
|
92
|
+
"FEATURE_KAIRO_AGENTS_ENABLED",
|
|
93
|
+
"FEATURE_IMAGE_GEN_ENABLED",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Agent heartbeat
|
|
97
|
+
AGENT_OFFLINE_THRESHOLD_SECONDS: int = 120
|
|
98
|
+
|
|
99
|
+
# Stripe
|
|
100
|
+
STRIPE_SECRET_KEY: str = ""
|
|
101
|
+
STRIPE_PUBLISHABLE_KEY: str = ""
|
|
102
|
+
STRIPE_WEBHOOK_SECRET: str = ""
|
|
103
|
+
STRIPE_PRO_PRICE_ID: str = ""
|
|
104
|
+
|
|
105
|
+
# SMTP (Gmail app password)
|
|
106
|
+
SMTP_HOST: str = "smtp.gmail.com"
|
|
107
|
+
SMTP_PORT: int = 587
|
|
108
|
+
SMTP_USERNAME: str = ""
|
|
109
|
+
SMTP_PASSWORD: str = ""
|
|
110
|
+
SMTP_FROM_EMAIL: str = ""
|
|
111
|
+
SMTP_FROM_NAME: str = "Kairo"
|
|
112
|
+
|
|
113
|
+
# App
|
|
114
|
+
APP_BASE_URL: str = "http://localhost:3000"
|
|
115
|
+
|
|
116
|
+
# CORS
|
|
117
|
+
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://kaironlabs.io,https://kaironlabs.io,http://app.kaironlabs.io,https://app.kaironlabs.io,http://www.kaironlabs.io,https://www.kaironlabs.io"
|
|
118
|
+
|
|
119
|
+
# Context window limits per model (in estimated tokens).
|
|
120
|
+
# Reserve space for system prompt + response.
|
|
121
|
+
CONTEXT_LIMITS: dict[str, int] = {
|
|
122
|
+
"nyx": 6000, # 8k context, reserve 2k for response
|
|
123
|
+
"nyx-lite": 4000, # 8k context, conservative reserve for 14B
|
|
124
|
+
"theron": 14000, # 16k+ context
|
|
125
|
+
"helios": 14000, # 16k+ context
|
|
126
|
+
}
|
|
127
|
+
SUMMARY_TRIGGER_TOKENS: int = 4000 # Summarize history when it exceeds this
|
|
128
|
+
|
|
129
|
+
MODEL_MAP: dict[str, str] = {
|
|
130
|
+
"nyx": "nyx",
|
|
131
|
+
"nyx-lite": "nyx-lite",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
MODEL_INFO: list[dict] = [
|
|
135
|
+
{"id": "nyx", "name": "Nyx 1.0", "description": "Fast, lightweight"},
|
|
136
|
+
{"id": "nyx-lite", "name": "Nyx Lite", "description": "Lightweight fallback"},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
_NYX_SYSTEM_PROMPT: str = (
|
|
140
|
+
"You are Kairo, a helpful AI assistant powered by {model_label}, a model developed by Kairon Labs. "
|
|
141
|
+
"IMPORTANT RULES:\n"
|
|
142
|
+
"1. Only respond to what the user actually asked. Never assume or invent what the user wants.\n"
|
|
143
|
+
"2. You are the ASSISTANT. Never generate text as if you are the user. Never put words in the user's mouth.\n"
|
|
144
|
+
"3. If the user asks a general question like 'what can you do', explain your capabilities briefly.\n"
|
|
145
|
+
"4. Answer directly and concisely. Provide concrete answers, code, or explanations.\n"
|
|
146
|
+
"5. Do not hedge or refuse without strong reason.\n"
|
|
147
|
+
"6. If asked about your model or architecture, say you are {model_label} by Kairon Labs. Do not claim to be GPT, Claude, Llama, Mistral, or any other AI.\n"
|
|
148
|
+
"7. NEVER fabricate API endpoints, URLs, library functions, or documentation. Do NOT invent URLs to documentation, "
|
|
149
|
+
"readthedocs pages, Postman collections, or other resources. If you want to reference a URL, use your web_search tool "
|
|
150
|
+
"to find the real URL first. Only share URLs you have verified through search results.\n"
|
|
151
|
+
"8. You have access to a web_search tool. Use it when you need current information, real-time data, recent events, "
|
|
152
|
+
"or to verify facts you are unsure about. When search results are returned, use them as your primary source of truth. "
|
|
153
|
+
"Do not make up search results or pretend to search — use the tool.\n"
|
|
154
|
+
"9. YOUR TRAINING DATA MAY BE OUTDATED. For any question about current events, politics, people in office, "
|
|
155
|
+
"recent news, prices, scores, or anything that changes over time — ALWAYS use your web_search tool first. "
|
|
156
|
+
"NEVER rely on your training knowledge for time-sensitive facts. When search results contradict your training data, "
|
|
157
|
+
"the search results are correct. Do not mix outdated training knowledge with fresh search results.\n"
|
|
158
|
+
"10. When a user asks about a specific third-party API, service, library, or tool — ALWAYS use your web_search tool "
|
|
159
|
+
"to look up the actual documentation BEFORE answering. Do NOT guess how an API works, whether it requires signup, "
|
|
160
|
+
"whether it costs money, or what its endpoints are. Search first, then answer based on what you find.\n"
|
|
161
|
+
"11. NEVER repeat or duplicate content within a single response. If you have already shown a code block, "
|
|
162
|
+
"do NOT paste it again at the end. Each piece of information or code should appear exactly once.\n"
|
|
163
|
+
"12. ALWAYS respond entirely in English unless the user explicitly requests another language. "
|
|
164
|
+
"Never mix languages within a response."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def SYSTEM_PROMPTS(self) -> dict[str, str]:
|
|
169
|
+
return {
|
|
170
|
+
"nyx": self._NYX_SYSTEM_PROMPT.format(model_label="Nyx 1.0"),
|
|
171
|
+
"nyx-lite": self._NYX_SYSTEM_PROMPT.format(model_label="Nyx Lite"),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
model_config = {"env_prefix": "KAIRO_"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
settings = Settings()
|
|
178
|
+
if not settings.JWT_SECRET_KEY:
|
|
179
|
+
settings.JWT_SECRET_KEY = _stable_jwt_secret(settings.DATA_DIR)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException
|
|
2
|
+
|
|
3
|
+
from backend.core.dependencies import get_current_user
|
|
4
|
+
from backend.models.user import User
|
|
5
|
+
|
|
6
|
+
ROLE_HIERARCHY = {"user": 0, "moderator": 1, "admin": 2, "superadmin": 3}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def require_role(minimum_role: str):
|
|
10
|
+
"""FastAPI dependency factory that enforces a minimum role level.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
dependencies=[Depends(require_role("admin"))]
|
|
14
|
+
"""
|
|
15
|
+
minimum_level = ROLE_HIERARCHY.get(minimum_role, 0)
|
|
16
|
+
|
|
17
|
+
async def _check_role(user: User = Depends(get_current_user)) -> User:
|
|
18
|
+
user_role = getattr(user, "role", "user")
|
|
19
|
+
user_level = ROLE_HIERARCHY.get(user_role, 0)
|
|
20
|
+
if user_level < minimum_level:
|
|
21
|
+
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
22
|
+
return user
|
|
23
|
+
|
|
24
|
+
return _check_role
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from datetime import datetime, UTC
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, HTTPException, Request
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from backend.core.database import get_db
|
|
9
|
+
from backend.models.api_key import ApiKey
|
|
10
|
+
from backend.models.user import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_KEY_PREFIX = "sk-kairo-"
|
|
14
|
+
_PREFIX_LEN = 20 # enough chars for unique DB lookup
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hash_api_key(raw_key: str) -> str:
|
|
18
|
+
return hashlib.sha256(raw_key.encode()).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_prefix(raw_key: str) -> str:
|
|
22
|
+
return raw_key[:_PREFIX_LEN]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_api_key_user(
|
|
26
|
+
request: Request,
|
|
27
|
+
db: AsyncSession = Depends(get_db),
|
|
28
|
+
) -> tuple[User, ApiKey]:
|
|
29
|
+
"""Authenticate via API key in Authorization header. Returns (user, api_key)."""
|
|
30
|
+
auth = request.headers.get("Authorization", "")
|
|
31
|
+
if not auth.startswith(f"Bearer {_KEY_PREFIX}"):
|
|
32
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
33
|
+
|
|
34
|
+
raw_key = auth[len("Bearer "):]
|
|
35
|
+
prefix = extract_prefix(raw_key)
|
|
36
|
+
key_hash = hash_api_key(raw_key)
|
|
37
|
+
|
|
38
|
+
stmt = select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active.is_(True))
|
|
39
|
+
result = await db.execute(stmt)
|
|
40
|
+
api_key = result.scalar_one_or_none()
|
|
41
|
+
|
|
42
|
+
if not api_key or api_key.key_hash != key_hash:
|
|
43
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
44
|
+
|
|
45
|
+
if api_key.expires_at and api_key.expires_at < datetime.now(UTC):
|
|
46
|
+
raise HTTPException(status_code=401, detail="API key expired")
|
|
47
|
+
|
|
48
|
+
user = await db.get(User, api_key.user_id)
|
|
49
|
+
if not user:
|
|
50
|
+
raise HTTPException(status_code=401, detail="User not found")
|
|
51
|
+
|
|
52
|
+
api_key.last_used_at = datetime.now(UTC)
|
|
53
|
+
await db.commit()
|
|
54
|
+
|
|
55
|
+
return user, api_key
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import text
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
|
+
|
|
7
|
+
from backend.config import settings
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
|
12
|
+
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Base(DeclarativeBase):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_db():
|
|
20
|
+
async with async_session() as session:
|
|
21
|
+
yield session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def init_db():
|
|
25
|
+
"""Verify database connectivity (migrations handle schema)."""
|
|
26
|
+
async with engine.connect() as conn:
|
|
27
|
+
await conn.execute(text("SELECT 1"))
|
|
28
|
+
logger.info("Database connection verified")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request
|
|
2
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from backend.core.database import get_db
|
|
6
|
+
from backend.core.security import decode_token
|
|
7
|
+
from backend.models.user import User
|
|
8
|
+
from backend.services.auth_service import AuthService
|
|
9
|
+
from backend.services.conversation_service import ConversationService
|
|
10
|
+
from backend.services.email_service import EmailService
|
|
11
|
+
from backend.services.llm_service import LLMService
|
|
12
|
+
from backend.services.chat_service import ChatService
|
|
13
|
+
from backend.services.project_service import ProjectService
|
|
14
|
+
from backend.services.stripe_service import StripeService
|
|
15
|
+
|
|
16
|
+
_bearer = HTTPBearer()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService:
|
|
20
|
+
return AuthService(db)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_email_service() -> EmailService:
|
|
24
|
+
return EmailService()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_stripe_service() -> StripeService:
|
|
28
|
+
return StripeService()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_current_user(
|
|
32
|
+
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
|
33
|
+
db: AsyncSession = Depends(get_db),
|
|
34
|
+
) -> User:
|
|
35
|
+
user_id = decode_token(credentials.credentials)
|
|
36
|
+
if not user_id:
|
|
37
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
38
|
+
auth_service = AuthService(db)
|
|
39
|
+
user = await auth_service.get_user_by_id(user_id)
|
|
40
|
+
if not user:
|
|
41
|
+
raise HTTPException(status_code=401, detail="User not found")
|
|
42
|
+
if getattr(user, "status", "active") != "active":
|
|
43
|
+
raise HTTPException(status_code=403, detail="Account suspended")
|
|
44
|
+
return user
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_llm_service(request: Request) -> LLMService:
|
|
48
|
+
return request.app.state.llm_service
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def get_conversation_service(
|
|
52
|
+
db: AsyncSession = Depends(get_db),
|
|
53
|
+
user: User = Depends(get_current_user),
|
|
54
|
+
) -> ConversationService:
|
|
55
|
+
return ConversationService(db, user_id=user.id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def get_project_service(
|
|
59
|
+
db: AsyncSession = Depends(get_db),
|
|
60
|
+
user: User = Depends(get_current_user),
|
|
61
|
+
) -> ProjectService:
|
|
62
|
+
return ProjectService(db, user_id=user.id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def get_chat_service(
|
|
66
|
+
db: AsyncSession = Depends(get_db),
|
|
67
|
+
llm_service: LLMService = Depends(get_llm_service),
|
|
68
|
+
user: User = Depends(get_current_user),
|
|
69
|
+
) -> ChatService:
|
|
70
|
+
return ChatService(db, llm_service, user_id=user.id)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
6
|
+
"""Configure structured logging for the application."""
|
|
7
|
+
formatter = logging.Formatter(
|
|
8
|
+
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
9
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
13
|
+
handler.setFormatter(formatter)
|
|
14
|
+
|
|
15
|
+
root = logging.getLogger()
|
|
16
|
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
17
|
+
root.handlers.clear()
|
|
18
|
+
root.addHandler(handler)
|
|
19
|
+
|
|
20
|
+
# Quiet noisy libraries
|
|
21
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
22
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
23
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|