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,225 @@
1
+ """OpenAI-compatible API endpoint — mounted at /v1/ for SDK compatibility."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ import uuid
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from fastapi.responses import StreamingResponse
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from backend.config import settings
13
+ from backend.core.api_key_auth import get_api_key_user
14
+ from backend.core.database import get_db
15
+ from backend.core.rate_limit import rate_limit_api
16
+ from backend.models.api_key import ApiKey
17
+ from backend.models.user import User
18
+ from backend.schemas.openai_compat import (
19
+ ChatCompletionChunk,
20
+ ChatCompletionRequest,
21
+ ChatCompletionResponse,
22
+ ChatCompletionChoice,
23
+ ChatMessage,
24
+ ChoiceDelta,
25
+ StreamChoice,
26
+ UsageInfo,
27
+ )
28
+ from backend.services.api_usage_service import ApiUsageService
29
+ from backend.services.llm_service import LLMService
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(prefix="/v1", tags=["OpenAI Compatible"])
34
+
35
+
36
+ def _get_llm_service(request: Request) -> LLMService:
37
+ return request.app.state.llm_service
38
+
39
+
40
+ @router.post("/chat/completions")
41
+ async def chat_completions(
42
+ req: ChatCompletionRequest,
43
+ request: Request,
44
+ auth: tuple[User, ApiKey] = Depends(get_api_key_user),
45
+ db: AsyncSession = Depends(get_db),
46
+ llm_service: LLMService = Depends(_get_llm_service),
47
+ _rate: None = Depends(rate_limit_api),
48
+ ):
49
+ if not settings.FEATURE_KAIRO_API_ENABLED:
50
+ raise HTTPException(status_code=503, detail="Kairo API is coming soon.")
51
+
52
+ user, api_key = auth
53
+
54
+ # Check API usage limits
55
+ usage_svc = ApiUsageService(db)
56
+ allowed, reason = await usage_svc.check_limits(user.id)
57
+ if not allowed:
58
+ raise HTTPException(status_code=429, detail=reason)
59
+
60
+ # Resolve model
61
+ model_id = llm_service.resolve_model(req.model)
62
+ client, base_url, resolved_model, _is_fallback = llm_service._select_backend(req.model)
63
+
64
+ # Build vLLM payload — serialize full request, override model with resolved name
65
+ payload = req.model_dump(exclude_none=True)
66
+ payload["model"] = resolved_model
67
+
68
+ request_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
69
+ created = int(time.time())
70
+
71
+ if req.stream:
72
+ payload["stream_options"] = {"include_usage": True}
73
+ return StreamingResponse(
74
+ _stream_proxy(
75
+ client, base_url, payload, request_id, created, resolved_model,
76
+ api_key.id, user.id, db,
77
+ ),
78
+ media_type="text/event-stream",
79
+ )
80
+ else:
81
+ # Non-streaming: proxy full request
82
+ try:
83
+ resp = await client.post(f"{base_url}/v1/chat/completions", json=payload)
84
+ resp.raise_for_status()
85
+ data = resp.json()
86
+ except Exception as e:
87
+ logger.error("vLLM proxy error: %s", e)
88
+ raise HTTPException(status_code=502, detail="Inference backend unavailable")
89
+
90
+ # Record usage
91
+ usage = data.get("usage", {})
92
+ prompt_tokens = usage.get("prompt_tokens", 0)
93
+ completion_tokens = usage.get("completion_tokens", 0)
94
+ await usage_svc.record(
95
+ api_key_id=api_key.id, user_id=user.id, model=resolved_model,
96
+ prompt_tokens=prompt_tokens, completion_tokens=completion_tokens,
97
+ endpoint="/v1/chat/completions",
98
+ )
99
+
100
+ # Reformat as our response — forward all message fields including tool_calls
101
+ choices = []
102
+ for c in data.get("choices", []):
103
+ msg = c.get("message", {})
104
+ choices.append(ChatCompletionChoice(
105
+ index=c.get("index", 0),
106
+ message=ChatMessage(
107
+ role=msg.get("role", "assistant"),
108
+ content=msg.get("content"),
109
+ name=msg.get("name"),
110
+ tool_calls=msg.get("tool_calls"),
111
+ tool_call_id=msg.get("tool_call_id"),
112
+ ),
113
+ finish_reason=c.get("finish_reason"),
114
+ ))
115
+
116
+ return ChatCompletionResponse(
117
+ id=request_id, created=created, model=resolved_model,
118
+ choices=choices,
119
+ usage=UsageInfo(
120
+ prompt_tokens=prompt_tokens,
121
+ completion_tokens=completion_tokens,
122
+ total_tokens=prompt_tokens + completion_tokens,
123
+ ) if usage else None,
124
+ )
125
+
126
+
127
+ async def _stream_proxy(
128
+ client, base_url, payload, request_id, created, model,
129
+ api_key_id, user_id, db,
130
+ ):
131
+ """Stream SSE from vLLM, relay in OpenAI format, record usage at end."""
132
+ prompt_tokens = 0
133
+ completion_tokens = 0
134
+
135
+ try:
136
+ async with client.stream("POST", f"{base_url}/v1/chat/completions", json=payload) as response:
137
+ response.raise_for_status()
138
+ async for line in response.aiter_lines():
139
+ if not line.startswith("data: "):
140
+ continue
141
+ data_str = line[6:]
142
+ if data_str.strip() == "[DONE]":
143
+ break
144
+ try:
145
+ chunk = json.loads(data_str)
146
+ # Capture usage
147
+ if "usage" in chunk and chunk["usage"]:
148
+ prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
149
+ completion_tokens = chunk["usage"].get("completion_tokens", 0)
150
+
151
+ choices = chunk.get("choices", [])
152
+ if not choices:
153
+ # Emit usage-only final chunk if present
154
+ if prompt_tokens or completion_tokens:
155
+ usage_chunk = ChatCompletionChunk(
156
+ id=request_id, created=created, model=model,
157
+ choices=[],
158
+ usage=UsageInfo(
159
+ prompt_tokens=prompt_tokens,
160
+ completion_tokens=completion_tokens,
161
+ total_tokens=prompt_tokens + completion_tokens,
162
+ ),
163
+ )
164
+ yield f"data: {usage_chunk.model_dump_json()}\n\n"
165
+ continue
166
+
167
+ # Forward all choices, including tool_calls deltas
168
+ stream_choices = []
169
+ for c in choices:
170
+ delta = c.get("delta", {})
171
+ stream_choices.append(StreamChoice(
172
+ index=c.get("index", 0),
173
+ delta=ChoiceDelta(
174
+ role=delta.get("role"),
175
+ content=delta.get("content"),
176
+ tool_calls=delta.get("tool_calls"),
177
+ ),
178
+ finish_reason=c.get("finish_reason"),
179
+ ))
180
+ out = ChatCompletionChunk(
181
+ id=request_id, created=created, model=model,
182
+ choices=stream_choices,
183
+ )
184
+ yield f"data: {out.model_dump_json()}\n\n"
185
+ except (json.JSONDecodeError, KeyError, IndexError):
186
+ continue
187
+
188
+ yield "data: [DONE]\n\n"
189
+ except Exception as e:
190
+ logger.error("Stream proxy error: %s", e)
191
+ yield f"data: {json.dumps({'error': 'Inference backend error'})}\n\n"
192
+ yield "data: [DONE]\n\n"
193
+
194
+ # Record usage after stream completes
195
+ if prompt_tokens or completion_tokens:
196
+ try:
197
+ usage_svc = ApiUsageService(db)
198
+ await usage_svc.record(
199
+ api_key_id=api_key_id, user_id=user_id, model=model,
200
+ prompt_tokens=prompt_tokens, completion_tokens=completion_tokens,
201
+ endpoint="/v1/chat/completions",
202
+ )
203
+ except Exception as e:
204
+ logger.error("Failed to record API usage: %s", e)
205
+
206
+
207
+ @router.get("/models")
208
+ async def list_models(
209
+ llm_service: LLMService = Depends(_get_llm_service),
210
+ ):
211
+ """List available models — public endpoint, no auth required."""
212
+ models = await llm_service.list_available_models()
213
+ return {
214
+ "object": "list",
215
+ "data": [
216
+ {
217
+ "id": m["id"],
218
+ "object": "model",
219
+ "owned_by": "kairon-labs",
220
+ "permission": [],
221
+ }
222
+ for m in models
223
+ if m.get("available", True)
224
+ ],
225
+ }
@@ -0,0 +1,102 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from backend.core.dependencies import get_project_service
4
+ from backend.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
5
+ from backend.services.project_service import ProjectService
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.post("/", response_model=ProjectResponse)
11
+ async def create_project(
12
+ body: ProjectCreate,
13
+ service: ProjectService = Depends(get_project_service),
14
+ ):
15
+ project = await service.create(body.name)
16
+ return {
17
+ "id": project.id,
18
+ "name": project.name,
19
+ "instructions": project.instructions,
20
+ "conversation_count": 0,
21
+ "created_at": project.created_at,
22
+ "updated_at": project.updated_at,
23
+ }
24
+
25
+
26
+ @router.get("/", response_model=list[ProjectResponse])
27
+ async def list_projects(
28
+ service: ProjectService = Depends(get_project_service),
29
+ ):
30
+ return await service.list_all()
31
+
32
+
33
+ @router.get("/{project_id}", response_model=ProjectResponse)
34
+ async def get_project(
35
+ project_id: str,
36
+ service: ProjectService = Depends(get_project_service),
37
+ ):
38
+ project = await service.get(project_id)
39
+ if not project:
40
+ raise HTTPException(status_code=404, detail="Project not found")
41
+ return {
42
+ "id": project.id,
43
+ "name": project.name,
44
+ "instructions": project.instructions,
45
+ "conversation_count": len(project.conversations),
46
+ "created_at": project.created_at,
47
+ "updated_at": project.updated_at,
48
+ }
49
+
50
+
51
+ @router.patch("/{project_id}", response_model=ProjectResponse)
52
+ async def update_project(
53
+ project_id: str,
54
+ body: ProjectUpdate,
55
+ service: ProjectService = Depends(get_project_service),
56
+ ):
57
+ updates = body.model_dump(exclude_unset=True)
58
+ if not updates:
59
+ raise HTTPException(status_code=400, detail="No fields to update")
60
+ project = await service.update(project_id, **updates)
61
+ if not project:
62
+ raise HTTPException(status_code=404, detail="Project not found")
63
+ return {
64
+ "id": project.id,
65
+ "name": project.name,
66
+ "instructions": project.instructions,
67
+ "conversation_count": len(project.conversations),
68
+ "created_at": project.created_at,
69
+ "updated_at": project.updated_at,
70
+ }
71
+
72
+
73
+ @router.delete("/{project_id}", status_code=204)
74
+ async def delete_project(
75
+ project_id: str,
76
+ service: ProjectService = Depends(get_project_service),
77
+ ):
78
+ deleted = await service.delete(project_id)
79
+ if not deleted:
80
+ raise HTTPException(status_code=404, detail="Project not found")
81
+
82
+
83
+ @router.post("/{project_id}/conversations/{conversation_id}", status_code=204)
84
+ async def add_conversation_to_project(
85
+ project_id: str,
86
+ conversation_id: str,
87
+ service: ProjectService = Depends(get_project_service),
88
+ ):
89
+ added = await service.add_conversation(project_id, conversation_id)
90
+ if not added:
91
+ raise HTTPException(status_code=404, detail="Project or conversation not found")
92
+
93
+
94
+ @router.delete("/{project_id}/conversations/{conversation_id}", status_code=204)
95
+ async def remove_conversation_from_project(
96
+ project_id: str,
97
+ conversation_id: str,
98
+ service: ProjectService = Depends(get_project_service),
99
+ ):
100
+ removed = await service.remove_conversation(conversation_id)
101
+ if not removed:
102
+ raise HTTPException(status_code=404, detail="Conversation not found")
@@ -0,0 +1,32 @@
1
+ from fastapi import APIRouter, Depends, Query
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from backend.core.database import get_db
5
+ from backend.core.dependencies import get_current_user
6
+ from backend.models.user import User
7
+ from backend.schemas.usage import UsageSummaryResponse, UsageHistoryResponse, UsageHistoryEntry
8
+ from backend.services.usage_service import UsageService
9
+
10
+ router = APIRouter(prefix="/usage", tags=["usage"])
11
+
12
+
13
+ @router.get("/summary", response_model=UsageSummaryResponse)
14
+ async def usage_summary(
15
+ user: User = Depends(get_current_user),
16
+ db: AsyncSession = Depends(get_db),
17
+ ):
18
+ service = UsageService(db)
19
+ return await service.get_usage_summary(user.id)
20
+
21
+
22
+ @router.get("/history", response_model=UsageHistoryResponse)
23
+ async def usage_history(
24
+ days: int = Query(default=30, ge=1, le=365),
25
+ user: User = Depends(get_current_user),
26
+ db: AsyncSession = Depends(get_db),
27
+ ):
28
+ service = UsageService(db)
29
+ entries = await service.get_usage_history(user.id, days=days)
30
+ return UsageHistoryResponse(
31
+ entries=[UsageHistoryEntry(**e) for e in entries]
32
+ )
@@ -0,0 +1,79 @@
1
+ import logging
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from sqlalchemy import select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from backend.core.database import get_db
8
+ from backend.models.user import PlanType, User
9
+ from backend.services.stripe_service import StripeService
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
14
+
15
+
16
+ @router.post("/stripe")
17
+ async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)):
18
+ payload = await request.body()
19
+ sig = request.headers.get("stripe-signature", "")
20
+
21
+ stripe_service = StripeService()
22
+ try:
23
+ result = stripe_service.handle_webhook_event(payload, sig)
24
+ except Exception as e:
25
+ logger.warning("Webhook signature verification failed: %s", e)
26
+ raise HTTPException(status_code=400, detail="Invalid signature")
27
+
28
+ action = result.get("action")
29
+
30
+ if action == "upgrade":
31
+ customer_id = result["customer_id"]
32
+ subscription_id = result["subscription_id"]
33
+ user_id = result.get("user_id")
34
+
35
+ user = None
36
+ if user_id:
37
+ user = await db.get(User, user_id)
38
+ if not user:
39
+ stmt = select(User).where(User.stripe_customer_id == customer_id)
40
+ res = await db.execute(stmt)
41
+ user = res.scalar_one_or_none()
42
+ if user:
43
+ user.plan = PlanType.PRO.value
44
+ user.stripe_customer_id = customer_id
45
+ user.stripe_subscription_id = subscription_id
46
+ await db.commit()
47
+ logger.info("User %s upgraded to Pro", user.id)
48
+ else:
49
+ logger.error("Webhook upgrade: no user found for customer=%s user_id=%s", customer_id, user_id)
50
+
51
+ elif action == "downgrade":
52
+ customer_id = result["customer_id"]
53
+ stmt = select(User).where(User.stripe_customer_id == customer_id)
54
+ res = await db.execute(stmt)
55
+ user = res.scalar_one_or_none()
56
+ if user:
57
+ user.plan = PlanType.FREE.value
58
+ user.stripe_subscription_id = None
59
+ await db.commit()
60
+ logger.info("User %s downgraded to Free", user.id)
61
+ else:
62
+ logger.error("Webhook downgrade: no user found for customer=%s", customer_id)
63
+
64
+ elif action == "sync_active":
65
+ customer_id = result["customer_id"]
66
+ subscription_id = result["subscription_id"]
67
+ stmt = select(User).where(User.stripe_customer_id == customer_id)
68
+ res = await db.execute(stmt)
69
+ user = res.scalar_one_or_none()
70
+ if user:
71
+ user.plan = PlanType.PRO.value
72
+ user.stripe_subscription_id = subscription_id
73
+ await db.commit()
74
+
75
+ elif action == "payment_failed":
76
+ customer_id = result["customer_id"]
77
+ logger.warning("Payment failed for customer %s", customer_id)
78
+
79
+ return {"received": True}