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
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+
6
+ from backend.core.dependencies import get_auth_service, get_current_user, get_email_service
7
+ from backend.core.rate_limit import rate_limit_auth
8
+ from backend.core.security import create_access_token
9
+ from backend.models.user import User
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ from backend.schemas.auth import (
14
+ ForgotPasswordRequest,
15
+ LoginRequest,
16
+ RegisterRequest,
17
+ ResetPasswordRequest,
18
+ TokenResponse,
19
+ UserResponse,
20
+ VerifyEmailRequest,
21
+ )
22
+ from backend.services.auth_service import AuthService
23
+ from backend.services.email_service import EmailService
24
+
25
+ router = APIRouter(prefix="/auth", tags=["auth"])
26
+
27
+
28
+ @router.post("/register", response_model=TokenResponse, status_code=201, dependencies=[Depends(rate_limit_auth)])
29
+ async def register(
30
+ body: RegisterRequest,
31
+ auth_service: AuthService = Depends(get_auth_service),
32
+ email_service: EmailService = Depends(get_email_service),
33
+ ):
34
+ user = await auth_service.register(body.email, body.password)
35
+ if not user:
36
+ raise HTTPException(status_code=409, detail="Email already registered")
37
+ if user.email_verification_token:
38
+ try:
39
+ await asyncio.to_thread(
40
+ email_service.send_verification_email, user.email, user.email_verification_token
41
+ )
42
+ except Exception:
43
+ logger.exception("Failed to send verification email to %s", user.email)
44
+ token = create_access_token(user.id)
45
+ return TokenResponse(
46
+ access_token=token,
47
+ user=UserResponse.model_validate(user),
48
+ )
49
+
50
+
51
+ @router.post("/login", response_model=TokenResponse, dependencies=[Depends(rate_limit_auth)])
52
+ async def login(
53
+ body: LoginRequest,
54
+ auth_service: AuthService = Depends(get_auth_service),
55
+ ):
56
+ user = await auth_service.authenticate(body.email, body.password)
57
+ if not user:
58
+ raise HTTPException(status_code=401, detail="Invalid email or password")
59
+ token = create_access_token(user.id)
60
+ return TokenResponse(
61
+ access_token=token,
62
+ user=UserResponse.model_validate(user),
63
+ )
64
+
65
+
66
+ @router.get("/me", response_model=UserResponse)
67
+ async def get_me(user: User = Depends(get_current_user)):
68
+ return UserResponse.model_validate(user)
69
+
70
+
71
+ @router.post("/verify-email", dependencies=[Depends(rate_limit_auth)])
72
+ async def verify_email(
73
+ body: VerifyEmailRequest,
74
+ auth_service: AuthService = Depends(get_auth_service),
75
+ ):
76
+ user = await auth_service.verify_email(body.token)
77
+ if not user:
78
+ raise HTTPException(status_code=400, detail="Invalid or expired verification token")
79
+ return {"message": "Email verified successfully"}
80
+
81
+
82
+ @router.post("/forgot-password", dependencies=[Depends(rate_limit_auth)])
83
+ async def forgot_password(
84
+ body: ForgotPasswordRequest,
85
+ auth_service: AuthService = Depends(get_auth_service),
86
+ email_service: EmailService = Depends(get_email_service),
87
+ ):
88
+ """Always returns 200 to prevent email enumeration."""
89
+ token = await auth_service.request_password_reset(body.email)
90
+ if token:
91
+ await asyncio.to_thread(email_service.send_password_reset_email, body.email, token)
92
+ return {"message": "If that email exists, a reset link has been sent"}
93
+
94
+
95
+ @router.post("/reset-password", dependencies=[Depends(rate_limit_auth)])
96
+ async def reset_password(
97
+ body: ResetPasswordRequest,
98
+ auth_service: AuthService = Depends(get_auth_service),
99
+ ):
100
+ success = await auth_service.reset_password(body.token, body.password)
101
+ if not success:
102
+ raise HTTPException(status_code=400, detail="Invalid or expired reset token")
103
+ return {"message": "Password reset successfully"}
104
+
105
+
106
+ @router.post("/resend-verification", dependencies=[Depends(rate_limit_auth)])
107
+ async def resend_verification(
108
+ user: User = Depends(get_current_user),
109
+ auth_service: AuthService = Depends(get_auth_service),
110
+ email_service: EmailService = Depends(get_email_service),
111
+ ):
112
+ token = await auth_service.regenerate_verification_token(user.id)
113
+ if not token:
114
+ return {"message": "Email already verified"}
115
+ await asyncio.to_thread(email_service.send_verification_email, user.email, token)
116
+ return {"message": "Verification email sent"}
@@ -0,0 +1,41 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from backend.core.dependencies import get_current_user, get_stripe_service
4
+ from backend.models.user import User
5
+ from backend.schemas.auth import BillingPortalResponse, BillingStatusResponse, CheckoutResponse
6
+ from backend.services.stripe_service import StripeService
7
+
8
+ router = APIRouter(prefix="/billing", tags=["billing"])
9
+
10
+
11
+ @router.post("/create-checkout", response_model=CheckoutResponse)
12
+ async def create_checkout(
13
+ user: User = Depends(get_current_user),
14
+ stripe_service: StripeService = Depends(get_stripe_service),
15
+ ):
16
+ if user.plan == "pro":
17
+ raise HTTPException(status_code=400, detail="Already on Pro plan")
18
+ checkout_url, customer_id = stripe_service.create_checkout_session(
19
+ user.id, user.email, user.stripe_customer_id
20
+ )
21
+ return CheckoutResponse(checkout_url=checkout_url)
22
+
23
+
24
+ @router.post("/portal", response_model=BillingPortalResponse)
25
+ async def billing_portal(
26
+ user: User = Depends(get_current_user),
27
+ stripe_service: StripeService = Depends(get_stripe_service),
28
+ ):
29
+ if not user.stripe_customer_id:
30
+ raise HTTPException(status_code=400, detail="No billing account found")
31
+ portal_url = stripe_service.create_billing_portal_session(user.stripe_customer_id)
32
+ return BillingPortalResponse(portal_url=portal_url)
33
+
34
+
35
+ @router.get("/status", response_model=BillingStatusResponse)
36
+ async def billing_status(user: User = Depends(get_current_user)):
37
+ return BillingStatusResponse(
38
+ plan=user.plan,
39
+ stripe_customer_id=user.stripe_customer_id,
40
+ stripe_subscription_id=user.stripe_subscription_id,
41
+ )
@@ -0,0 +1,72 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from pydantic import BaseModel, Field
3
+ from starlette.responses import StreamingResponse
4
+
5
+ from backend.core.dependencies import get_chat_service, get_conversation_service
6
+ from backend.core.rate_limit import rate_limit_chat
7
+ from backend.schemas.chat import ChatRequest
8
+ from backend.services.chat_service import ChatService
9
+ from backend.services.conversation_service import ConversationService
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/chat", dependencies=[Depends(rate_limit_chat)])
15
+ async def chat(
16
+ request: ChatRequest,
17
+ chat_service: ChatService = Depends(get_chat_service),
18
+ ):
19
+ return StreamingResponse(
20
+ chat_service.stream_response(
21
+ message=request.message,
22
+ model=request.model,
23
+ conversation_id=request.conversation_id,
24
+ temperature=request.temperature,
25
+ max_tokens=request.max_tokens,
26
+ project_id=request.project_id,
27
+ ),
28
+ media_type="text/event-stream",
29
+ headers={
30
+ "Cache-Control": "no-cache",
31
+ "Connection": "keep-alive",
32
+ "X-Accel-Buffering": "no",
33
+ },
34
+ )
35
+
36
+
37
+ class EditMessageRequest(BaseModel):
38
+ content: str = Field(..., min_length=1, max_length=32000)
39
+
40
+
41
+ @router.put("/chat/messages/{message_id}")
42
+ async def edit_message(
43
+ message_id: str,
44
+ request: EditMessageRequest,
45
+ service: ConversationService = Depends(get_conversation_service),
46
+ ):
47
+ msg = await service.edit_message(message_id, request.content)
48
+ if not msg:
49
+ raise HTTPException(status_code=404, detail="Message not found")
50
+ return {
51
+ "id": msg.id,
52
+ "conversation_id": msg.conversation_id,
53
+ "role": msg.role,
54
+ "content": msg.content,
55
+ "created_at": msg.created_at,
56
+ }
57
+
58
+
59
+ @router.post("/chat/regenerate/{conversation_id}", dependencies=[Depends(rate_limit_chat)])
60
+ async def regenerate(
61
+ conversation_id: str,
62
+ chat_service: ChatService = Depends(get_chat_service),
63
+ ):
64
+ return StreamingResponse(
65
+ chat_service.regenerate_response(conversation_id=conversation_id),
66
+ media_type="text/event-stream",
67
+ headers={
68
+ "Cache-Control": "no-cache",
69
+ "Connection": "keep-alive",
70
+ "X-Accel-Buffering": "no",
71
+ },
72
+ )
@@ -0,0 +1,125 @@
1
+ import json
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query
4
+ from starlette.responses import Response
5
+
6
+ from backend.core.dependencies import get_conversation_service
7
+ from backend.schemas.conversation import (
8
+ ConversationDetail,
9
+ ConversationRename,
10
+ ConversationSummary,
11
+ MessageSchema,
12
+ )
13
+ from backend.services.conversation_service import ConversationService
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/")
19
+ async def list_conversations(
20
+ limit: int = Query(default=50, ge=1, le=100),
21
+ offset: int = Query(default=0, ge=0),
22
+ q: str = Query(default="", max_length=200),
23
+ project_id: str | None = Query(default=None),
24
+ service: ConversationService = Depends(get_conversation_service),
25
+ ):
26
+ if q.strip():
27
+ items, total = await service.search(q.strip(), limit=limit, offset=offset)
28
+ else:
29
+ items, total = await service.list_all(limit=limit, offset=offset, project_id=project_id)
30
+ return {"items": items, "total": total}
31
+
32
+
33
+ @router.get("/search-messages")
34
+ async def search_messages(
35
+ q: str = Query(..., min_length=2, max_length=200),
36
+ limit: int = Query(default=20, ge=1, le=50),
37
+ offset: int = Query(default=0, ge=0),
38
+ service: ConversationService = Depends(get_conversation_service),
39
+ ):
40
+ results = await service.search_messages(q, limit=limit, offset=offset)
41
+ return {"results": results}
42
+
43
+
44
+ @router.get("/{conversation_id}", response_model=ConversationDetail)
45
+ async def get_conversation(
46
+ conversation_id: str,
47
+ service: ConversationService = Depends(get_conversation_service),
48
+ ):
49
+ conv = await service.get(conversation_id)
50
+ if not conv:
51
+ raise HTTPException(status_code=404, detail="Conversation not found")
52
+ return conv
53
+
54
+
55
+ @router.delete("/{conversation_id}", status_code=204)
56
+ async def delete_conversation(
57
+ conversation_id: str,
58
+ service: ConversationService = Depends(get_conversation_service),
59
+ ):
60
+ deleted = await service.delete(conversation_id)
61
+ if not deleted:
62
+ raise HTTPException(status_code=404, detail="Conversation not found")
63
+
64
+
65
+ @router.patch("/{conversation_id}")
66
+ async def rename_conversation(
67
+ conversation_id: str,
68
+ body: ConversationRename,
69
+ service: ConversationService = Depends(get_conversation_service),
70
+ ):
71
+ conv = await service.rename(conversation_id, body.title)
72
+ if not conv:
73
+ raise HTTPException(status_code=404, detail="Conversation not found")
74
+ return {"id": conv.id, "title": conv.title}
75
+
76
+
77
+ @router.get("/{conversation_id}/export")
78
+ async def export_conversation(
79
+ conversation_id: str,
80
+ format: str = Query(default="md", pattern="^(md|json)$"),
81
+ service: ConversationService = Depends(get_conversation_service),
82
+ ):
83
+ conv = await service.get(conversation_id)
84
+ if not conv:
85
+ raise HTTPException(status_code=404, detail="Conversation not found")
86
+
87
+ messages = sorted(conv.messages, key=lambda m: m.created_at)
88
+ safe_title = conv.title.replace(" ", "_").replace("/", "-")[:50]
89
+
90
+ if format == "json":
91
+ data = {
92
+ "title": conv.title,
93
+ "model": conv.model,
94
+ "created_at": str(conv.created_at),
95
+ "updated_at": str(conv.updated_at),
96
+ "messages": [
97
+ {
98
+ "role": m.role,
99
+ "content": m.content,
100
+ "created_at": str(m.created_at),
101
+ }
102
+ for m in messages
103
+ ],
104
+ }
105
+ return Response(
106
+ content=json.dumps(data, indent=2, ensure_ascii=False),
107
+ media_type="application/json",
108
+ headers={"Content-Disposition": f'attachment; filename="{safe_title}.json"'},
109
+ )
110
+
111
+ # Markdown format
112
+ lines = [f"# {conv.title}\n"]
113
+ lines.append(f"**Model:** {conv.model} ")
114
+ lines.append(f"**Date:** {conv.created_at}\n")
115
+ lines.append("---\n")
116
+ for m in messages:
117
+ label = "User" if m.role == "user" else "Assistant"
118
+ lines.append(f"## {label}\n")
119
+ lines.append(f"{m.content}\n")
120
+ content = "\n".join(lines)
121
+ return Response(
122
+ content=content,
123
+ media_type="text/markdown",
124
+ headers={"Content-Disposition": f'attachment; filename="{safe_title}.md"'},
125
+ )
@@ -0,0 +1,100 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from backend.config import settings
5
+ from backend.core.database import get_db
6
+ from backend.core.dependencies import get_current_user
7
+ from backend.core.rate_limit import rate_limit_auth
8
+ from backend.models.user import PlanType, User
9
+ from backend.schemas.device_auth import (
10
+ DeviceApproveRequest,
11
+ DeviceApproveResponse,
12
+ DeviceCodeRequest,
13
+ DeviceCodeResponse,
14
+ DeviceTokenRequest,
15
+ DeviceTokenResponse,
16
+ )
17
+ from backend.services.device_auth_service import DeviceAuthService
18
+
19
+ router = APIRouter(prefix="/auth/device", tags=["Device Auth"])
20
+
21
+ _ERROR_DESCRIPTIONS = {
22
+ "authorization_pending": "The user has not yet approved the request.",
23
+ "slow_down": "Polling too frequently. Increase interval.",
24
+ "expired_token": "The device code has expired. Please restart authentication.",
25
+ "access_denied": "The user denied the request or does not have a Max plan.",
26
+ }
27
+
28
+
29
+ @router.post(
30
+ "/code",
31
+ response_model=DeviceCodeResponse,
32
+ dependencies=[Depends(rate_limit_auth)],
33
+ )
34
+ async def request_device_code(
35
+ body: DeviceCodeRequest,
36
+ db: AsyncSession = Depends(get_db),
37
+ ):
38
+ """Initiate the device code flow (RFC 8628). Called by the CLI."""
39
+ svc = DeviceAuthService(db)
40
+ device = await svc.create_device_code(body.client_name)
41
+
42
+ base_url = settings.APP_BASE_URL.rstrip("/")
43
+ return DeviceCodeResponse(
44
+ device_code=device.device_code,
45
+ user_code=device.user_code,
46
+ verification_uri=f"{base_url}/cli-auth",
47
+ verification_uri_complete=f"{base_url}/cli-auth?code={device.user_code}",
48
+ expires_in=DeviceAuthService.EXPIRY_MINUTES * 60,
49
+ interval=device.interval,
50
+ )
51
+
52
+
53
+ @router.post("/token")
54
+ async def poll_device_token(
55
+ body: DeviceTokenRequest,
56
+ db: AsyncSession = Depends(get_db),
57
+ ):
58
+ """Poll for the access token. Called by the CLI at the configured interval."""
59
+ svc = DeviceAuthService(db)
60
+ status, user_info = await svc.poll_token(body.device_code)
61
+
62
+ if status == "approved" and user_info:
63
+ return DeviceTokenResponse(
64
+ access_token=user_info["raw_key"],
65
+ plan=user_info["plan"],
66
+ email=user_info["email"],
67
+ )
68
+
69
+ # RFC 8628: return 400 with structured error for non-success states
70
+ raise HTTPException(
71
+ status_code=400,
72
+ detail={
73
+ "error": status,
74
+ "error_description": _ERROR_DESCRIPTIONS.get(status, "Unknown error"),
75
+ },
76
+ )
77
+
78
+
79
+ @router.post("/approve", response_model=DeviceApproveResponse)
80
+ async def approve_device(
81
+ body: DeviceApproveRequest,
82
+ user: User = Depends(get_current_user),
83
+ db: AsyncSession = Depends(get_db),
84
+ ):
85
+ """Approve a pending device code. Called from the web UI by a logged-in user."""
86
+ if user.plan != PlanType.MAX.value:
87
+ raise HTTPException(
88
+ status_code=403,
89
+ detail="Kairo Code requires a Max plan.",
90
+ )
91
+
92
+ svc = DeviceAuthService(db)
93
+ result = await svc.approve(body.user_code, user)
94
+ if not result:
95
+ raise HTTPException(status_code=404, detail="Invalid or expired code.")
96
+
97
+ return DeviceApproveResponse(
98
+ approved=True,
99
+ message="CLI authorized successfully.",
100
+ )
@@ -0,0 +1,83 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
5
+ from pydantic import BaseModel
6
+
7
+ from backend.core.dependencies import get_current_user
8
+ from backend.models.user import User
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ router = APIRouter()
13
+
14
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
15
+
16
+ TEXT_EXTENSIONS = {
17
+ ".txt", ".md", ".py", ".js", ".ts", ".tsx", ".jsx", ".css", ".html",
18
+ ".json", ".yaml", ".yml", ".xml", ".csv", ".sh", ".bash", ".zsh",
19
+ ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp", ".swift",
20
+ ".kt", ".scala", ".r", ".sql", ".toml", ".ini", ".cfg", ".conf",
21
+ ".env", ".gitignore", ".dockerfile", ".makefile",
22
+ }
23
+
24
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
25
+
26
+
27
+ class FileUploadResponse(BaseModel):
28
+ filename: str
29
+ text: str
30
+ type: str
31
+
32
+
33
+ @router.post("/files/upload", response_model=FileUploadResponse)
34
+ async def upload_file(
35
+ file: UploadFile = File(...),
36
+ user: User = Depends(get_current_user),
37
+ ):
38
+ if not file.filename:
39
+ raise HTTPException(status_code=400, detail="No filename provided.")
40
+
41
+ content = await file.read()
42
+ if len(content) > MAX_FILE_SIZE:
43
+ raise HTTPException(status_code=413, detail="File too large. Maximum size is 10 MB.")
44
+
45
+ ext = Path(file.filename).suffix.lower()
46
+
47
+ if ext in TEXT_EXTENSIONS:
48
+ try:
49
+ text = content.decode("utf-8")
50
+ except UnicodeDecodeError:
51
+ try:
52
+ text = content.decode("latin-1")
53
+ except Exception:
54
+ raise HTTPException(status_code=400, detail="Could not decode file as text.")
55
+ return FileUploadResponse(filename=file.filename, text=text, type="text")
56
+
57
+ if ext in IMAGE_EXTENSIONS:
58
+ return FileUploadResponse(
59
+ filename=file.filename,
60
+ text=f"[Image: {file.filename} ({len(content) // 1024} KB)]",
61
+ type="image",
62
+ )
63
+
64
+ if ext == ".pdf":
65
+ return FileUploadResponse(
66
+ filename=file.filename,
67
+ text=f"[PDF: {file.filename} ({len(content) // 1024} KB)]",
68
+ type="pdf",
69
+ )
70
+
71
+ if ext in (".doc", ".docx"):
72
+ return FileUploadResponse(
73
+ filename=file.filename,
74
+ text=f"[Document: {file.filename} ({len(content) // 1024} KB)]",
75
+ type="document",
76
+ )
77
+
78
+ # Fallback: try to read as text
79
+ try:
80
+ text = content.decode("utf-8")
81
+ return FileUploadResponse(filename=file.filename, text=text, type="text")
82
+ except UnicodeDecodeError:
83
+ raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
@@ -0,0 +1,36 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from backend.core.database import get_db
5
+ from backend.core.dependencies import get_llm_service
6
+ from backend.schemas.status import StatusPageResponse
7
+ from backend.services.llm_service import LLMService
8
+ from backend.services.status_service import StatusService
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/health")
14
+ async def health(llm_service: LLMService = Depends(get_llm_service)):
15
+ vllm_ok = await llm_service.check_health()
16
+ return {
17
+ "status": "ok",
18
+ "vllm": "connected" if vllm_ok else "disconnected",
19
+ }
20
+
21
+
22
+ @router.get("/models")
23
+ async def list_models(llm_service: LLMService = Depends(get_llm_service)):
24
+ models = await llm_service.list_available_models()
25
+ return {"models": models}
26
+
27
+
28
+ @router.get("/status", response_model=StatusPageResponse)
29
+ async def get_status_page(
30
+ db: AsyncSession = Depends(get_db),
31
+ llm_service: LLMService = Depends(get_llm_service),
32
+ ):
33
+ """Public status page endpoint. No authentication required."""
34
+ svc = StatusService(db, llm_service)
35
+ data = await svc.get_status_page()
36
+ return StatusPageResponse(**data)
@@ -0,0 +1,80 @@
1
+ import logging
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from backend.config import settings
7
+ from backend.core.dependencies import get_current_user, get_db
8
+ from backend.models.user import User
9
+ from backend.schemas.image import ImageGenerateRequest, ImageGenerateResponse
10
+ from backend.services.image_service import ImageService
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/images/generate", response_model=ImageGenerateResponse)
18
+ async def generate_image(
19
+ request: ImageGenerateRequest,
20
+ user: User = Depends(get_current_user),
21
+ db: AsyncSession = Depends(get_db),
22
+ ):
23
+ if not settings.FEATURE_IMAGE_GEN_ENABLED:
24
+ raise HTTPException(status_code=404, detail="Image generation is not available.")
25
+
26
+ service = ImageService(db, user_id=user.id)
27
+
28
+ allowed, reason = await service.check_access()
29
+ if not allowed:
30
+ raise HTTPException(status_code=403, detail=reason)
31
+
32
+ try:
33
+ result = await service.generate(
34
+ prompt=request.prompt,
35
+ conversation_id=request.conversation_id,
36
+ width=request.width,
37
+ height=request.height,
38
+ )
39
+ return ImageGenerateResponse(**result)
40
+ except Exception:
41
+ logger.exception("Image generation failed")
42
+ raise HTTPException(status_code=500, detail="Image generation failed. Please try again.")
43
+
44
+
45
+ @router.post("/images/img2img", response_model=ImageGenerateResponse)
46
+ async def img2img(
47
+ image: UploadFile = File(...),
48
+ prompt: str = Form(..., min_length=1, max_length=2000),
49
+ conversation_id: str | None = Form(default=None),
50
+ strength: float = Form(default=0.75, ge=0.0, le=1.0),
51
+ user: User = Depends(get_current_user),
52
+ db: AsyncSession = Depends(get_db),
53
+ ):
54
+ if not settings.FEATURE_IMAGE_GEN_ENABLED:
55
+ raise HTTPException(status_code=404, detail="Image generation is not available.")
56
+
57
+ service = ImageService(db, user_id=user.id)
58
+
59
+ allowed, reason = await service.check_access()
60
+ if not allowed:
61
+ raise HTTPException(status_code=403, detail=reason)
62
+
63
+ if image.content_type not in ("image/png", "image/jpeg", "image/webp"):
64
+ raise HTTPException(status_code=400, detail="Image must be PNG, JPEG, or WebP.")
65
+
66
+ image_bytes = await image.read()
67
+ if len(image_bytes) > 10 * 1024 * 1024:
68
+ raise HTTPException(status_code=413, detail="Image too large. Maximum 10 MB.")
69
+
70
+ try:
71
+ result = await service.generate_img2img(
72
+ image_bytes=image_bytes,
73
+ prompt=prompt,
74
+ conversation_id=conversation_id,
75
+ strength=strength,
76
+ )
77
+ return ImageGenerateResponse(**result)
78
+ except Exception:
79
+ logger.exception("img2img generation failed")
80
+ raise HTTPException(status_code=500, detail="Image generation failed. Please try again.")