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.
Files changed (144) hide show
  1. image-service/main.py +178 -0
  2. infra/chat/app/main.py +84 -0
  3. kairo/backend/__init__.py +0 -0
  4. kairo/backend/api/__init__.py +0 -0
  5. kairo/backend/api/admin/__init__.py +23 -0
  6. kairo/backend/api/admin/audit.py +54 -0
  7. kairo/backend/api/admin/content.py +142 -0
  8. kairo/backend/api/admin/incidents.py +148 -0
  9. kairo/backend/api/admin/stats.py +125 -0
  10. kairo/backend/api/admin/system.py +87 -0
  11. kairo/backend/api/admin/users.py +279 -0
  12. kairo/backend/api/agents.py +94 -0
  13. kairo/backend/api/api_keys.py +85 -0
  14. kairo/backend/api/auth.py +116 -0
  15. kairo/backend/api/billing.py +41 -0
  16. kairo/backend/api/chat.py +72 -0
  17. kairo/backend/api/conversations.py +125 -0
  18. kairo/backend/api/device_auth.py +100 -0
  19. kairo/backend/api/files.py +83 -0
  20. kairo/backend/api/health.py +36 -0
  21. kairo/backend/api/images.py +80 -0
  22. kairo/backend/api/openai_compat.py +225 -0
  23. kairo/backend/api/projects.py +102 -0
  24. kairo/backend/api/usage.py +32 -0
  25. kairo/backend/api/webhooks.py +79 -0
  26. kairo/backend/app.py +297 -0
  27. kairo/backend/config.py +179 -0
  28. kairo/backend/core/__init__.py +0 -0
  29. kairo/backend/core/admin_auth.py +24 -0
  30. kairo/backend/core/api_key_auth.py +55 -0
  31. kairo/backend/core/database.py +28 -0
  32. kairo/backend/core/dependencies.py +70 -0
  33. kairo/backend/core/logging.py +23 -0
  34. kairo/backend/core/rate_limit.py +73 -0
  35. kairo/backend/core/security.py +29 -0
  36. kairo/backend/models/__init__.py +19 -0
  37. kairo/backend/models/agent.py +30 -0
  38. kairo/backend/models/api_key.py +25 -0
  39. kairo/backend/models/api_usage.py +29 -0
  40. kairo/backend/models/audit_log.py +26 -0
  41. kairo/backend/models/conversation.py +48 -0
  42. kairo/backend/models/device_code.py +30 -0
  43. kairo/backend/models/feature_flag.py +21 -0
  44. kairo/backend/models/image_generation.py +24 -0
  45. kairo/backend/models/incident.py +28 -0
  46. kairo/backend/models/project.py +28 -0
  47. kairo/backend/models/uptime_record.py +24 -0
  48. kairo/backend/models/usage.py +24 -0
  49. kairo/backend/models/user.py +49 -0
  50. kairo/backend/schemas/__init__.py +0 -0
  51. kairo/backend/schemas/admin/__init__.py +0 -0
  52. kairo/backend/schemas/admin/audit.py +28 -0
  53. kairo/backend/schemas/admin/content.py +53 -0
  54. kairo/backend/schemas/admin/stats.py +77 -0
  55. kairo/backend/schemas/admin/system.py +44 -0
  56. kairo/backend/schemas/admin/users.py +48 -0
  57. kairo/backend/schemas/agent.py +42 -0
  58. kairo/backend/schemas/api_key.py +30 -0
  59. kairo/backend/schemas/auth.py +57 -0
  60. kairo/backend/schemas/chat.py +26 -0
  61. kairo/backend/schemas/conversation.py +39 -0
  62. kairo/backend/schemas/device_auth.py +40 -0
  63. kairo/backend/schemas/image.py +15 -0
  64. kairo/backend/schemas/openai_compat.py +76 -0
  65. kairo/backend/schemas/project.py +21 -0
  66. kairo/backend/schemas/status.py +81 -0
  67. kairo/backend/schemas/usage.py +15 -0
  68. kairo/backend/services/__init__.py +0 -0
  69. kairo/backend/services/admin/__init__.py +0 -0
  70. kairo/backend/services/admin/audit_service.py +78 -0
  71. kairo/backend/services/admin/content_service.py +119 -0
  72. kairo/backend/services/admin/incident_service.py +94 -0
  73. kairo/backend/services/admin/stats_service.py +281 -0
  74. kairo/backend/services/admin/system_service.py +126 -0
  75. kairo/backend/services/admin/user_service.py +157 -0
  76. kairo/backend/services/agent_service.py +107 -0
  77. kairo/backend/services/api_key_service.py +66 -0
  78. kairo/backend/services/api_usage_service.py +126 -0
  79. kairo/backend/services/auth_service.py +101 -0
  80. kairo/backend/services/chat_service.py +501 -0
  81. kairo/backend/services/conversation_service.py +264 -0
  82. kairo/backend/services/device_auth_service.py +193 -0
  83. kairo/backend/services/email_service.py +55 -0
  84. kairo/backend/services/image_service.py +181 -0
  85. kairo/backend/services/llm_service.py +186 -0
  86. kairo/backend/services/project_service.py +109 -0
  87. kairo/backend/services/status_service.py +167 -0
  88. kairo/backend/services/stripe_service.py +78 -0
  89. kairo/backend/services/usage_service.py +150 -0
  90. kairo/backend/services/web_search_service.py +96 -0
  91. kairo/migrations/env.py +60 -0
  92. kairo/migrations/versions/001_initial.py +55 -0
  93. kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
  94. kairo/migrations/versions/003_username_to_email.py +21 -0
  95. kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
  96. kairo/migrations/versions/005_add_projects.py +52 -0
  97. kairo/migrations/versions/006_add_image_generation.py +63 -0
  98. kairo/migrations/versions/007_add_admin_portal.py +107 -0
  99. kairo/migrations/versions/008_add_device_code_auth.py +76 -0
  100. kairo/migrations/versions/009_add_status_page.py +65 -0
  101. kairo/tools/extract_claude_data.py +465 -0
  102. kairo/tools/filter_claude_data.py +303 -0
  103. kairo/tools/generate_curated_data.py +157 -0
  104. kairo/tools/mix_training_data.py +295 -0
  105. kairo_code/__init__.py +3 -0
  106. kairo_code/agents/__init__.py +25 -0
  107. kairo_code/agents/architect.py +98 -0
  108. kairo_code/agents/audit.py +100 -0
  109. kairo_code/agents/base.py +463 -0
  110. kairo_code/agents/coder.py +155 -0
  111. kairo_code/agents/database.py +77 -0
  112. kairo_code/agents/docs.py +88 -0
  113. kairo_code/agents/explorer.py +62 -0
  114. kairo_code/agents/guardian.py +80 -0
  115. kairo_code/agents/planner.py +66 -0
  116. kairo_code/agents/reviewer.py +91 -0
  117. kairo_code/agents/security.py +94 -0
  118. kairo_code/agents/terraform.py +88 -0
  119. kairo_code/agents/testing.py +97 -0
  120. kairo_code/agents/uiux.py +88 -0
  121. kairo_code/auth.py +232 -0
  122. kairo_code/config.py +172 -0
  123. kairo_code/conversation.py +173 -0
  124. kairo_code/heartbeat.py +63 -0
  125. kairo_code/llm.py +291 -0
  126. kairo_code/logging_config.py +156 -0
  127. kairo_code/main.py +818 -0
  128. kairo_code/router.py +217 -0
  129. kairo_code/sandbox.py +248 -0
  130. kairo_code/settings.py +183 -0
  131. kairo_code/tools/__init__.py +51 -0
  132. kairo_code/tools/analysis.py +509 -0
  133. kairo_code/tools/base.py +417 -0
  134. kairo_code/tools/code.py +58 -0
  135. kairo_code/tools/definitions.py +617 -0
  136. kairo_code/tools/files.py +315 -0
  137. kairo_code/tools/review.py +390 -0
  138. kairo_code/tools/search.py +185 -0
  139. kairo_code/ui.py +418 -0
  140. kairo_code-0.1.0.dist-info/METADATA +13 -0
  141. kairo_code-0.1.0.dist-info/RECORD +144 -0
  142. kairo_code-0.1.0.dist-info/WHEEL +5 -0
  143. kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
  144. 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)
@@ -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)