wright 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.
- api/__init__.py +0 -0
- api/auth.py +165 -0
- api/chroma_cache.py +98 -0
- api/embedder.py +39 -0
- api/main.py +264 -0
- api/observability.py +74 -0
- api/quota.py +451 -0
- api/rate_limit.py +20 -0
- api/repo_store.py +67 -0
- api/routes/__init__.py +0 -0
- api/routes/auth.py +341 -0
- api/routes/billing.py +303 -0
- api/routes/chat.py +120 -0
- api/routes/coverage.py +120 -0
- api/routes/drift.py +593 -0
- api/routes/fix_pr.py +420 -0
- api/routes/generate.py +200 -0
- api/routes/internal.py +38 -0
- api/routes/llms_txt.py +79 -0
- api/routes/repos.py +854 -0
- api/routes/usage.py +19 -0
- api/routes/webhooks.py +156 -0
- api/tasks/__init__.py +0 -0
- api/tasks/email_tasks.py +413 -0
- api/tasks/ops_alerts.py +198 -0
- api/token_store.py +61 -0
- api/usage_store.py +215 -0
- api/user_store.py +182 -0
- cli/__init__.py +0 -0
- cli/main.py +1125 -0
- core/__init__.py +0 -0
- core/config.py +142 -0
- core/drift/__init__.py +0 -0
- core/drift/drift_detector.py +564 -0
- core/embeddings/__init__.py +0 -0
- core/embeddings/chroma_store.py +142 -0
- core/embeddings/pgvector_store.py +191 -0
- core/embeddings/voyage_embeddings.py +74 -0
- core/llm/__init__.py +0 -0
- core/llm/gateway.py +605 -0
- core/llm/graph.py +179 -0
- core/llm/prompts.py +450 -0
- core/llm/schema.py +31 -0
- core/output/__init__.py +0 -0
- core/output/injector.py +524 -0
- core/output/llms_txt.py +37 -0
- core/output/markdown_writer.py +96 -0
- core/output/openapi_gen.py +77 -0
- core/parser/__init__.py +0 -0
- core/parser/ast_chunker.py +131 -0
- core/parser/cache.py +480 -0
- core/parser/dep_graph.py +89 -0
- core/parser/tree_sitter_parser.py +1111 -0
- core/retrieval/__init__.py +0 -0
- core/retrieval/hybrid_retriever.py +368 -0
- mcp_server/__init__.py +0 -0
- mcp_server/server.py +374 -0
- wright-0.1.0.dist-info/METADATA +557 -0
- wright-0.1.0.dist-info/RECORD +64 -0
- wright-0.1.0.dist-info/WHEEL +5 -0
- wright-0.1.0.dist-info/entry_points.txt +4 -0
- wright-0.1.0.dist-info/licenses/LICENSE +661 -0
- wright-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL.md +43 -0
- wright-0.1.0.dist-info/top_level.txt +4 -0
api/quota.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Data models
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PlanLimits:
|
|
19
|
+
id: str
|
|
20
|
+
display_name: str
|
|
21
|
+
docs_per_month: int # -1 = unlimited
|
|
22
|
+
chat_messages_per_month: int
|
|
23
|
+
drift_checks_per_month: int
|
|
24
|
+
repos_limit: int
|
|
25
|
+
api_keys_limit: int
|
|
26
|
+
semantic_drift_enabled: bool
|
|
27
|
+
auto_pr_enabled: bool
|
|
28
|
+
github_action_comments_enabled: bool
|
|
29
|
+
llms_txt_enabled: bool
|
|
30
|
+
overage_rate_per_doc: float = 0.0 # $/doc above quota; 0 = hard block
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class QuotaResult:
|
|
35
|
+
allowed: bool
|
|
36
|
+
warning: bool # True when ≥80% consumed and not yet at limit
|
|
37
|
+
used: int
|
|
38
|
+
limit: int # -1 = unlimited
|
|
39
|
+
plan: str
|
|
40
|
+
pct: int = 0 # 0–100; 0 when unlimited
|
|
41
|
+
overage: bool = False # True when allowed via soft overage (Pro)
|
|
42
|
+
upgrade_url: str = "https://www.wrightai.live/pricing"
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.limit > 0:
|
|
46
|
+
self.pct = min(100, int(self.used / self.limit * 100))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Free-plan defaults (fallback when DB is unavailable — fail-open)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_FREE_LIMITS = PlanLimits(
|
|
54
|
+
id="free",
|
|
55
|
+
display_name="Free",
|
|
56
|
+
docs_per_month=500,
|
|
57
|
+
chat_messages_per_month=300,
|
|
58
|
+
drift_checks_per_month=200,
|
|
59
|
+
repos_limit=1,
|
|
60
|
+
api_keys_limit=1,
|
|
61
|
+
semantic_drift_enabled=True,
|
|
62
|
+
auto_pr_enabled=False,
|
|
63
|
+
github_action_comments_enabled=False,
|
|
64
|
+
llms_txt_enabled=True,
|
|
65
|
+
overage_rate_per_doc=0.0,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_PRO_LIMITS = PlanLimits(
|
|
69
|
+
id="pro",
|
|
70
|
+
display_name="Pro",
|
|
71
|
+
docs_per_month=1500,
|
|
72
|
+
chat_messages_per_month=1000,
|
|
73
|
+
drift_checks_per_month=1000,
|
|
74
|
+
repos_limit=5,
|
|
75
|
+
api_keys_limit=5,
|
|
76
|
+
semantic_drift_enabled=True,
|
|
77
|
+
auto_pr_enabled=True,
|
|
78
|
+
github_action_comments_enabled=True,
|
|
79
|
+
llms_txt_enabled=True,
|
|
80
|
+
overage_rate_per_doc=0.0,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_PLAN_DEFAULTS: dict[str, PlanLimits] = {
|
|
84
|
+
"free": _FREE_LIMITS,
|
|
85
|
+
"pro": _PRO_LIMITS,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_UNLIMITED_RESULT = QuotaResult(allowed=True, warning=False, used=0, limit=-1, plan="unknown")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# DB helpers (lazy, fail-open)
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _db():
|
|
97
|
+
from api.user_store import _db as _get_db
|
|
98
|
+
|
|
99
|
+
return _get_db()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Quota caches — eliminate 4 Supabase round-trips on every hot-path request
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
_quota_lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
# api_key → (plan_id, ts) — plan changes are rare, 5-min TTL is fine
|
|
109
|
+
_plan_id_cache: dict[str, tuple[str, float]] = {}
|
|
110
|
+
_PLAN_ID_TTL = 300.0
|
|
111
|
+
|
|
112
|
+
# plan_id → (PlanLimits, ts) — plan config almost never changes
|
|
113
|
+
_plan_limits_cache: dict[str, tuple[PlanLimits, float]] = {}
|
|
114
|
+
_PLAN_LIMITS_TTL = 3600.0
|
|
115
|
+
|
|
116
|
+
# api_key → (user_id, ts) — user_id is immutable once created
|
|
117
|
+
_user_id_cache: dict[str, tuple[str | None, float]] = {}
|
|
118
|
+
_USER_ID_TTL = 86400.0
|
|
119
|
+
|
|
120
|
+
# (user_id, event_type) → (count, ts) — short TTL: quota must be roughly fresh
|
|
121
|
+
_usage_count_cache: dict[tuple[str, str], tuple[int, float]] = {}
|
|
122
|
+
_USAGE_COUNT_TTL = 30.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_plan_limits(plan_id: str) -> PlanLimits:
|
|
126
|
+
"""Fetch plan limits, cached for 1 hour — plan config is nearly static."""
|
|
127
|
+
now = time.monotonic()
|
|
128
|
+
with _quota_lock:
|
|
129
|
+
if plan_id in _plan_limits_cache:
|
|
130
|
+
limits, ts = _plan_limits_cache[plan_id]
|
|
131
|
+
if now - ts < _PLAN_LIMITS_TTL:
|
|
132
|
+
return limits
|
|
133
|
+
try:
|
|
134
|
+
result = _db().table("plans").select("*").eq("id", plan_id).execute()
|
|
135
|
+
if result.data:
|
|
136
|
+
row = result.data[0]
|
|
137
|
+
limits = PlanLimits(
|
|
138
|
+
id=row["id"],
|
|
139
|
+
display_name=row["display_name"],
|
|
140
|
+
docs_per_month=row["docs_per_month"],
|
|
141
|
+
chat_messages_per_month=row["chat_messages_per_month"],
|
|
142
|
+
drift_checks_per_month=row["drift_checks_per_month"],
|
|
143
|
+
repos_limit=row["repos_limit"],
|
|
144
|
+
api_keys_limit=row["api_keys_limit"],
|
|
145
|
+
semantic_drift_enabled=row["semantic_drift_enabled"],
|
|
146
|
+
auto_pr_enabled=row["auto_pr_enabled"],
|
|
147
|
+
github_action_comments_enabled=row["github_action_comments_enabled"],
|
|
148
|
+
llms_txt_enabled=row["llms_txt_enabled"],
|
|
149
|
+
overage_rate_per_doc=float(row.get("overage_rate_per_doc") or 0.0),
|
|
150
|
+
)
|
|
151
|
+
with _quota_lock:
|
|
152
|
+
_plan_limits_cache[plan_id] = (limits, now)
|
|
153
|
+
return limits
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return _PLAN_DEFAULTS.get(plan_id, _FREE_LIMITS)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_user_plan(api_key: str) -> str:
|
|
160
|
+
"""Return the plan ID, cached for 5 minutes."""
|
|
161
|
+
if not api_key or not api_key.startswith("wai_"):
|
|
162
|
+
return "free"
|
|
163
|
+
now = time.monotonic()
|
|
164
|
+
with _quota_lock:
|
|
165
|
+
if api_key in _plan_id_cache:
|
|
166
|
+
plan_id, ts = _plan_id_cache[api_key]
|
|
167
|
+
if now - ts < _PLAN_ID_TTL:
|
|
168
|
+
return plan_id
|
|
169
|
+
try:
|
|
170
|
+
result = _db().table("users").select("plan").eq("api_key", api_key).execute()
|
|
171
|
+
if result.data:
|
|
172
|
+
plan_id = result.data[0].get("plan", "free") or "free"
|
|
173
|
+
with _quota_lock:
|
|
174
|
+
_plan_id_cache[api_key] = (plan_id, now)
|
|
175
|
+
return plan_id
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return "free"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_user_id(api_key: str) -> str | None:
|
|
182
|
+
"""Return user_id, cached indefinitely — it never changes once created."""
|
|
183
|
+
now = time.monotonic()
|
|
184
|
+
with _quota_lock:
|
|
185
|
+
if api_key in _user_id_cache:
|
|
186
|
+
user_id, ts = _user_id_cache[api_key]
|
|
187
|
+
if now - ts < _USER_ID_TTL:
|
|
188
|
+
return user_id
|
|
189
|
+
try:
|
|
190
|
+
result = _db().table("users").select("id").eq("api_key", api_key).execute()
|
|
191
|
+
if result.data:
|
|
192
|
+
user_id = result.data[0]["id"]
|
|
193
|
+
with _quota_lock:
|
|
194
|
+
_user_id_cache[api_key] = (user_id, now)
|
|
195
|
+
return user_id
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _count_monthly_events(user_id: str, event_type: str) -> int:
|
|
202
|
+
"""Count this month's events, cached for 30s — short enough to catch overages."""
|
|
203
|
+
now = time.monotonic()
|
|
204
|
+
cache_key = (user_id, event_type)
|
|
205
|
+
with _quota_lock:
|
|
206
|
+
if cache_key in _usage_count_cache:
|
|
207
|
+
count, ts = _usage_count_cache[cache_key]
|
|
208
|
+
if now - ts < _USAGE_COUNT_TTL:
|
|
209
|
+
return count
|
|
210
|
+
try:
|
|
211
|
+
month_start = datetime.now(timezone.utc).strftime("%Y-%m") + "-01T00:00:00+00:00"
|
|
212
|
+
result = (
|
|
213
|
+
_db()
|
|
214
|
+
.table("usage_events")
|
|
215
|
+
.select("id", count="exact")
|
|
216
|
+
.eq("user_id", user_id)
|
|
217
|
+
.eq("event_type", event_type)
|
|
218
|
+
.gte("created_at", month_start)
|
|
219
|
+
.execute()
|
|
220
|
+
)
|
|
221
|
+
count = result.count or len(result.data or [])
|
|
222
|
+
with _quota_lock:
|
|
223
|
+
_usage_count_cache[cache_key] = (count, now)
|
|
224
|
+
return count
|
|
225
|
+
except Exception:
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _count_connected_repos(api_key: str) -> int:
|
|
230
|
+
"""Count repos stored on disk for this API key."""
|
|
231
|
+
try:
|
|
232
|
+
repos_base = Path(os.getenv("REPOS_BASE_PATH", "/tmp/wright_repos"))
|
|
233
|
+
user_dir_name = api_key[-12:].replace("/", "_").replace(".", "_")
|
|
234
|
+
user_dir = repos_base / user_dir_name
|
|
235
|
+
if not user_dir.exists():
|
|
236
|
+
return 0
|
|
237
|
+
return sum(1 for p in user_dir.iterdir() if p.is_dir() and (p / ".git").exists())
|
|
238
|
+
except Exception:
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _trigger_email_alert(api_key: str, pct: int, used: int, limit: int) -> None:
|
|
243
|
+
"""Best-effort: send the quota alert email inline. Never blocks quota enforcement."""
|
|
244
|
+
try:
|
|
245
|
+
from api.tasks.email_tasks import send_quota_alert
|
|
246
|
+
|
|
247
|
+
send_quota_alert(api_key, pct, used, limit)
|
|
248
|
+
except Exception:
|
|
249
|
+
pass # Never let email failures affect quota enforcement
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Public API
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
Feature = Literal["docs_generated", "chat_message", "drift_checks_run", "repo_connect"]
|
|
257
|
+
FlagFeature = Literal["semantic_drift", "auto_pr", "github_action_comments"]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_quota(
|
|
261
|
+
api_key: str,
|
|
262
|
+
feature: Feature,
|
|
263
|
+
raise_on_blocked: bool = True,
|
|
264
|
+
) -> QuotaResult:
|
|
265
|
+
"""
|
|
266
|
+
Check whether this API key is within its monthly quota for feature.
|
|
267
|
+
|
|
268
|
+
Only enforces for wai_ keys (hosted service). CLI/MCP users with a
|
|
269
|
+
server-level static key bypass quota enforcement entirely (fail-open).
|
|
270
|
+
|
|
271
|
+
Pro users with overage_rate_per_doc > 0 are allowed past their limit
|
|
272
|
+
(soft overage) — usage is still tracked for end-of-period billing.
|
|
273
|
+
|
|
274
|
+
Returns QuotaResult; raises HTTP 429/403 when blocked and raise_on_blocked=True.
|
|
275
|
+
Sends a quota alert email at ≥80% and ≥100% usage (dedup handled internally).
|
|
276
|
+
"""
|
|
277
|
+
from fastapi import HTTPException
|
|
278
|
+
|
|
279
|
+
if not api_key or not api_key.startswith("wai_"):
|
|
280
|
+
return _UNLIMITED_RESULT
|
|
281
|
+
|
|
282
|
+
plan_id = get_user_plan(api_key)
|
|
283
|
+
limits = get_plan_limits(plan_id)
|
|
284
|
+
|
|
285
|
+
limit_map: dict[Feature, int] = {
|
|
286
|
+
"docs_generated": limits.docs_per_month,
|
|
287
|
+
"chat_message": limits.chat_messages_per_month,
|
|
288
|
+
"drift_checks_run": limits.drift_checks_per_month,
|
|
289
|
+
"repo_connect": limits.repos_limit,
|
|
290
|
+
}
|
|
291
|
+
limit = limit_map.get(feature, -1)
|
|
292
|
+
|
|
293
|
+
if limit == -1:
|
|
294
|
+
return QuotaResult(allowed=True, warning=False, used=0, limit=-1, plan=plan_id)
|
|
295
|
+
|
|
296
|
+
# Zero means feature completely disabled on this plan (e.g. chat on Free)
|
|
297
|
+
if limit == 0:
|
|
298
|
+
if raise_on_blocked:
|
|
299
|
+
raise HTTPException(
|
|
300
|
+
status_code=403,
|
|
301
|
+
detail=_blocked_detail(feature, 0, 0, plan_id),
|
|
302
|
+
)
|
|
303
|
+
return QuotaResult(allowed=False, warning=False, used=0, limit=0, plan=plan_id)
|
|
304
|
+
|
|
305
|
+
user_id = _resolve_user_id(api_key)
|
|
306
|
+
if user_id is None:
|
|
307
|
+
return QuotaResult(allowed=True, warning=False, used=0, limit=limit, plan=plan_id)
|
|
308
|
+
|
|
309
|
+
used = (
|
|
310
|
+
_count_connected_repos(api_key)
|
|
311
|
+
if feature == "repo_connect"
|
|
312
|
+
else _count_monthly_events(user_id, feature)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
warning = int(limit * 0.8) <= used < limit
|
|
316
|
+
blocked = used >= limit
|
|
317
|
+
|
|
318
|
+
# Pro soft overage: allow past limit and track for billing
|
|
319
|
+
overage = False
|
|
320
|
+
if blocked and feature == "docs_generated" and limits.overage_rate_per_doc > 0:
|
|
321
|
+
blocked = False
|
|
322
|
+
overage = True
|
|
323
|
+
|
|
324
|
+
result = QuotaResult(
|
|
325
|
+
allowed=not blocked,
|
|
326
|
+
warning=warning,
|
|
327
|
+
used=used,
|
|
328
|
+
limit=limit,
|
|
329
|
+
plan=plan_id,
|
|
330
|
+
overage=overage,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Fire email alert at ≥80% (dedup handled inside send_quota_alert)
|
|
334
|
+
if result.pct >= 80:
|
|
335
|
+
_trigger_email_alert(api_key, result.pct, used, limit)
|
|
336
|
+
|
|
337
|
+
if blocked and raise_on_blocked:
|
|
338
|
+
raise HTTPException(
|
|
339
|
+
status_code=429,
|
|
340
|
+
detail=_blocked_detail(feature, used, limit, plan_id),
|
|
341
|
+
)
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def check_feature_flag(
|
|
346
|
+
api_key: str,
|
|
347
|
+
feature: FlagFeature,
|
|
348
|
+
raise_on_blocked: bool = True,
|
|
349
|
+
) -> bool:
|
|
350
|
+
"""
|
|
351
|
+
Check whether a boolean feature is enabled for the user's plan.
|
|
352
|
+
Raises HTTP 403 when disabled and raise_on_blocked=True.
|
|
353
|
+
CLI/MCP static keys always have all features enabled.
|
|
354
|
+
"""
|
|
355
|
+
from fastapi import HTTPException
|
|
356
|
+
|
|
357
|
+
if not api_key or not api_key.startswith("wai_"):
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
plan_id = get_user_plan(api_key)
|
|
361
|
+
limits = get_plan_limits(plan_id)
|
|
362
|
+
|
|
363
|
+
flag_map: dict[FlagFeature, bool] = {
|
|
364
|
+
"semantic_drift": limits.semantic_drift_enabled,
|
|
365
|
+
"auto_pr": limits.auto_pr_enabled,
|
|
366
|
+
"github_action_comments": limits.github_action_comments_enabled,
|
|
367
|
+
}
|
|
368
|
+
enabled = flag_map.get(feature, False)
|
|
369
|
+
|
|
370
|
+
if not enabled and raise_on_blocked:
|
|
371
|
+
raise HTTPException(
|
|
372
|
+
status_code=403,
|
|
373
|
+
detail={
|
|
374
|
+
"error": "feature_not_available",
|
|
375
|
+
"feature": feature,
|
|
376
|
+
"plan": plan_id,
|
|
377
|
+
"upgrade_url": "https://www.wrightai.live/pricing",
|
|
378
|
+
"message": "This feature requires a Pro plan. Upgrade at wrightai.live/pricing",
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
return enabled
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_full_quota_status(api_key: str) -> dict:
|
|
385
|
+
"""
|
|
386
|
+
Return a merged dict describing the user's plan and all quota usage.
|
|
387
|
+
Used by the /usage endpoint to power the dashboard.
|
|
388
|
+
"""
|
|
389
|
+
if not api_key or not api_key.startswith("wai_"):
|
|
390
|
+
return {"plan": "cli", "quotas": {}}
|
|
391
|
+
|
|
392
|
+
plan_id = get_user_plan(api_key)
|
|
393
|
+
limits = get_plan_limits(plan_id)
|
|
394
|
+
user_id = _resolve_user_id(api_key)
|
|
395
|
+
|
|
396
|
+
def _usage(event_type: str) -> int:
|
|
397
|
+
return _count_monthly_events(user_id, event_type) if user_id else 0
|
|
398
|
+
|
|
399
|
+
def _quota_entry(used: int, limit: int) -> dict:
|
|
400
|
+
pct = min(100, int(used / limit * 100)) if limit > 0 else 0
|
|
401
|
+
return {
|
|
402
|
+
"used": used,
|
|
403
|
+
"limit": limit,
|
|
404
|
+
"unlimited": limit == -1,
|
|
405
|
+
"pct": pct,
|
|
406
|
+
"warning": limit > 0 and pct >= 80 and used < limit,
|
|
407
|
+
"blocked": limit > 0 and used >= limit,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
docs_used = _usage("docs_generated")
|
|
411
|
+
chat_used = _usage("chat_message")
|
|
412
|
+
drift_used = _usage("drift_checks_run")
|
|
413
|
+
repos_used = _count_connected_repos(api_key)
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"plan": plan_id,
|
|
417
|
+
"plan_display": limits.display_name,
|
|
418
|
+
"features": {
|
|
419
|
+
"semantic_drift": limits.semantic_drift_enabled,
|
|
420
|
+
"auto_pr": limits.auto_pr_enabled,
|
|
421
|
+
"github_action_comments": limits.github_action_comments_enabled,
|
|
422
|
+
},
|
|
423
|
+
"quotas": {
|
|
424
|
+
"docs_generated": _quota_entry(docs_used, limits.docs_per_month),
|
|
425
|
+
"drift_checks": _quota_entry(drift_used, limits.drift_checks_per_month),
|
|
426
|
+
"chat_messages": _quota_entry(chat_used, limits.chat_messages_per_month),
|
|
427
|
+
"repos": _quota_entry(repos_used, limits.repos_limit),
|
|
428
|
+
},
|
|
429
|
+
"upgrade_url": "https://www.wrightai.live/pricing",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Internal helpers
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _blocked_detail(feature: str, used: int, limit: int, plan: str) -> dict:
|
|
439
|
+
label = feature.replace("_", " ")
|
|
440
|
+
return {
|
|
441
|
+
"error": "quota_exceeded",
|
|
442
|
+
"feature": feature,
|
|
443
|
+
"used": used,
|
|
444
|
+
"limit": limit,
|
|
445
|
+
"plan": plan,
|
|
446
|
+
"upgrade_url": "https://www.wrightai.live/pricing",
|
|
447
|
+
"message": (
|
|
448
|
+
f"You've used {used}/{limit} {label}s this month. "
|
|
449
|
+
"Upgrade to Pro for more at wrightai.live/pricing"
|
|
450
|
+
),
|
|
451
|
+
}
|
api/rate_limit.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from slowapi import Limiter
|
|
5
|
+
from slowapi.util import get_remote_address
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _rate_limit_key(request: Request) -> str:
|
|
9
|
+
"""Use the API key as the rate limit bucket for wai_ keys, IP otherwise.
|
|
10
|
+
|
|
11
|
+
This means each user gets their own independent limit regardless of shared
|
|
12
|
+
IPs (office NAT, VPN), and anonymous/CLI callers are bucketed by IP.
|
|
13
|
+
"""
|
|
14
|
+
api_key = request.headers.get("X-Wright-API-Key", "")
|
|
15
|
+
if api_key.startswith("wai_"):
|
|
16
|
+
return api_key
|
|
17
|
+
return get_remote_address(request)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
limiter = Limiter(key_func=_rate_limit_key)
|
api/repo_store.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
_logger = logging.getLogger("wright.repos.store")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _db():
|
|
10
|
+
from api.user_store import _db as _get_db
|
|
11
|
+
|
|
12
|
+
return _get_db()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_repo(user_id: str, repo_slug: str, meta: dict) -> None:
|
|
16
|
+
"""Upsert repo metadata for (user_id, repo_slug). meta has git_url, branch, local_path."""
|
|
17
|
+
try:
|
|
18
|
+
_db().table("repo_meta").upsert(
|
|
19
|
+
{
|
|
20
|
+
"user_id": user_id,
|
|
21
|
+
"repo_slug": repo_slug,
|
|
22
|
+
"git_url": meta.get("git_url", ""),
|
|
23
|
+
"branch": meta.get("branch", "main"),
|
|
24
|
+
"local_path": meta.get("local_path", ""),
|
|
25
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
26
|
+
},
|
|
27
|
+
on_conflict="user_id,repo_slug",
|
|
28
|
+
).execute()
|
|
29
|
+
except Exception:
|
|
30
|
+
_logger.exception(
|
|
31
|
+
"Failed to save repo meta for user_id=%s repo_slug=%s", user_id, repo_slug
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_repos(user_id: str) -> dict[str, dict]:
|
|
36
|
+
"""Return {repo_slug: {git_url, branch, local_path}} for user_id, {} on error/empty."""
|
|
37
|
+
try:
|
|
38
|
+
result = (
|
|
39
|
+
_db()
|
|
40
|
+
.table("repo_meta")
|
|
41
|
+
.select("repo_slug, git_url, branch, local_path")
|
|
42
|
+
.eq("user_id", user_id)
|
|
43
|
+
.execute()
|
|
44
|
+
)
|
|
45
|
+
return {
|
|
46
|
+
row["repo_slug"]: {
|
|
47
|
+
"git_url": row["git_url"],
|
|
48
|
+
"branch": row["branch"],
|
|
49
|
+
"local_path": row["local_path"],
|
|
50
|
+
}
|
|
51
|
+
for row in (result.data or [])
|
|
52
|
+
}
|
|
53
|
+
except Exception:
|
|
54
|
+
_logger.exception("Failed to list repos for user_id=%s", user_id)
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def delete_repo(user_id: str, repo_slug: str) -> None:
|
|
59
|
+
"""Delete repo metadata for (user_id, repo_slug)."""
|
|
60
|
+
try:
|
|
61
|
+
_db().table("repo_meta").delete().eq("user_id", user_id).eq(
|
|
62
|
+
"repo_slug", repo_slug
|
|
63
|
+
).execute()
|
|
64
|
+
except Exception:
|
|
65
|
+
_logger.exception(
|
|
66
|
+
"Failed to delete repo meta for user_id=%s repo_slug=%s", user_id, repo_slug
|
|
67
|
+
)
|
api/routes/__init__.py
ADDED
|
File without changes
|