cloudwright-ai-web 0.3.2__tar.gz → 0.3.5__tar.gz
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.
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/PKG-INFO +1 -1
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/__init__.py +1 -1
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/app.py +175 -22
- cloudwright_ai_web-0.3.5/cloudwright_web/static/assets/index-BigRPwDr.js +65 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/index.html +1 -1
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/App.tsx +79 -15
- cloudwright_ai_web-0.3.5/tests/test_agent_browser.py +660 -0
- cloudwright_ai_web-0.3.5/tests/test_api_behavioral.py +274 -0
- cloudwright_ai_web-0.3.5/tests/test_error_responses.py +76 -0
- cloudwright_ai_web-0.3.5/tests/test_rate_limiting.py +80 -0
- cloudwright_ai_web-0.3.5/tests/test_streaming_api.py +105 -0
- cloudwright_ai_web-0.3.5/tests/test_usage_tracking.py +76 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/.gitignore +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/README.md +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/py.typed +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/assets/index-fjOZ8Gop.js +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/index.html +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/package-lock.json +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/package.json +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/BoundaryNode.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/CloudServiceNode.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/CostTable.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/DiagramControls.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/DiagramLegend.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ExportPanel.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/NodeSidePanel.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/SpecPanel.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/SummaryBar.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ValidationPanel.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/lib/icons.ts +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/main.tsx +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/tsconfig.json +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/vite.config.ts +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/pyproject.toml +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/tests/__init__.py +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/tests/test_api.py +0 -0
- {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/tests/test_diagram_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudwright-ai-web
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Web UI for Cloudwright architecture intelligence
|
|
5
5
|
Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
|
|
6
6
|
Project-URL: Repository, https://github.com/xmpuspus/cloudwright
|
|
@@ -7,7 +7,9 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import multiprocessing
|
|
10
|
+
import threading
|
|
10
11
|
import time
|
|
12
|
+
from collections import deque
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Literal
|
|
13
15
|
|
|
@@ -51,29 +53,93 @@ app.add_middleware(
|
|
|
51
53
|
_architect: Architect | None = None
|
|
52
54
|
_catalog: Catalog | None = None
|
|
53
55
|
_cost_engine: CostEngine | None = None
|
|
56
|
+
_architect_lock = threading.Lock()
|
|
57
|
+
_catalog_lock = threading.Lock()
|
|
58
|
+
_cost_engine_lock = threading.Lock()
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
def get_architect() -> Architect:
|
|
57
62
|
global _architect
|
|
58
63
|
if _architect is None:
|
|
59
|
-
|
|
64
|
+
with _architect_lock:
|
|
65
|
+
if _architect is None:
|
|
66
|
+
_architect = Architect()
|
|
60
67
|
return _architect
|
|
61
68
|
|
|
62
69
|
|
|
63
70
|
def get_catalog() -> Catalog:
|
|
64
71
|
global _catalog
|
|
65
72
|
if _catalog is None:
|
|
66
|
-
|
|
73
|
+
with _catalog_lock:
|
|
74
|
+
if _catalog is None:
|
|
75
|
+
_catalog = Catalog()
|
|
67
76
|
return _catalog
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
def get_cost_engine() -> CostEngine:
|
|
71
80
|
global _cost_engine
|
|
72
81
|
if _cost_engine is None:
|
|
73
|
-
|
|
82
|
+
with _cost_engine_lock:
|
|
83
|
+
if _cost_engine is None:
|
|
84
|
+
_cost_engine = CostEngine()
|
|
74
85
|
return _cost_engine
|
|
75
86
|
|
|
76
87
|
|
|
88
|
+
# --- Rate limiter ---
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _RateLimiter:
|
|
92
|
+
"""Simple in-memory per-IP rate limiter."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, max_requests: int = 30, window_seconds: int = 60):
|
|
95
|
+
self._max = max_requests
|
|
96
|
+
self._window = window_seconds
|
|
97
|
+
self._buckets: dict[str, deque] = {}
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
|
|
100
|
+
def is_allowed(self, ip: str) -> tuple[bool, int]:
|
|
101
|
+
"""Returns (allowed, retry_after_seconds)."""
|
|
102
|
+
now = time.time()
|
|
103
|
+
cutoff = now - self._window
|
|
104
|
+
with self._lock:
|
|
105
|
+
if ip not in self._buckets:
|
|
106
|
+
self._buckets[ip] = deque()
|
|
107
|
+
bucket = self._buckets[ip]
|
|
108
|
+
while bucket and bucket[0] < cutoff:
|
|
109
|
+
bucket.popleft()
|
|
110
|
+
if len(bucket) >= self._max:
|
|
111
|
+
retry_after = int(self._window - (now - bucket[0])) + 1 if bucket else int(self._window) + 1
|
|
112
|
+
return False, retry_after
|
|
113
|
+
bucket.append(now)
|
|
114
|
+
return True, 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_rate_limiter = _RateLimiter(max_requests=30, window_seconds=60)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Error response helper ---
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _error_response(code: str, message: str, suggestion: str, status_code: int = 400) -> JSONResponse:
|
|
124
|
+
return JSONResponse(
|
|
125
|
+
status_code=status_code,
|
|
126
|
+
content={"code": code, "message": message, "suggestion": suggestion},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_rate_limit(request: Request):
|
|
131
|
+
ip = request.client.host if request.client else "unknown"
|
|
132
|
+
allowed, retry_after = _rate_limiter.is_allowed(ip)
|
|
133
|
+
if not allowed:
|
|
134
|
+
return _error_response(
|
|
135
|
+
"rate_limited",
|
|
136
|
+
"Too many requests",
|
|
137
|
+
f"Wait {retry_after} seconds before retrying",
|
|
138
|
+
status_code=429,
|
|
139
|
+
)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
77
143
|
# --- Spec cache ---
|
|
78
144
|
|
|
79
145
|
|
|
@@ -200,13 +266,18 @@ def health():
|
|
|
200
266
|
|
|
201
267
|
|
|
202
268
|
@app.post("/api/design")
|
|
203
|
-
async def design(req: DesignRequest):
|
|
269
|
+
async def design(req: DesignRequest, request: Request):
|
|
270
|
+
if (err := _check_rate_limit(request)):
|
|
271
|
+
return err
|
|
204
272
|
try:
|
|
205
273
|
architect = get_architect()
|
|
206
274
|
constraints = None
|
|
207
275
|
if req.budget_monthly or req.compliance:
|
|
208
276
|
constraints = Constraints(budget_monthly=req.budget_monthly, compliance=req.compliance)
|
|
209
|
-
|
|
277
|
+
try:
|
|
278
|
+
spec = await asyncio.wait_for(asyncio.to_thread(architect.design, req.description, constraints), timeout=120)
|
|
279
|
+
except asyncio.TimeoutError:
|
|
280
|
+
return _error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
|
|
210
281
|
try:
|
|
211
282
|
cost_estimate = await asyncio.to_thread(get_cost_engine().estimate, spec)
|
|
212
283
|
spec = spec.model_copy(update={"cost_estimate": cost_estimate})
|
|
@@ -215,12 +286,12 @@ async def design(req: DesignRequest):
|
|
|
215
286
|
return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml()}
|
|
216
287
|
except RuntimeError as e:
|
|
217
288
|
if "No LLM provider" in str(e):
|
|
218
|
-
|
|
289
|
+
return _error_response("missing_api_key", str(e), "Set an LLM provider API key in your environment", 503)
|
|
219
290
|
log.exception("Design endpoint failed")
|
|
220
|
-
|
|
221
|
-
except Exception
|
|
291
|
+
return _error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
292
|
+
except Exception:
|
|
222
293
|
log.exception("Design endpoint failed")
|
|
223
|
-
|
|
294
|
+
return _error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
224
295
|
|
|
225
296
|
|
|
226
297
|
@app.post("/api/design/stream")
|
|
@@ -267,15 +338,20 @@ async def design_stream(req: DesignRequest):
|
|
|
267
338
|
|
|
268
339
|
|
|
269
340
|
@app.post("/api/modify")
|
|
270
|
-
async def modify(req: ModifyRequest):
|
|
341
|
+
async def modify(req: ModifyRequest, request: Request):
|
|
342
|
+
if (err := _check_rate_limit(request)):
|
|
343
|
+
return err
|
|
271
344
|
try:
|
|
272
345
|
architect = get_architect()
|
|
273
346
|
spec = ArchSpec.model_validate(req.spec)
|
|
274
|
-
|
|
347
|
+
try:
|
|
348
|
+
updated = await asyncio.wait_for(asyncio.to_thread(architect.modify, spec, req.instruction), timeout=120)
|
|
349
|
+
except asyncio.TimeoutError:
|
|
350
|
+
return _error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
|
|
275
351
|
return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml()}
|
|
276
|
-
except Exception
|
|
352
|
+
except Exception:
|
|
277
353
|
log.exception("Modify endpoint failed")
|
|
278
|
-
|
|
354
|
+
return _error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
279
355
|
|
|
280
356
|
|
|
281
357
|
@app.post("/api/modify/stream")
|
|
@@ -480,7 +556,9 @@ def get_icon(provider: str, service: str):
|
|
|
480
556
|
|
|
481
557
|
|
|
482
558
|
@app.post("/api/chat")
|
|
483
|
-
async def chat(req: ChatRequest):
|
|
559
|
+
async def chat(req: ChatRequest, request: Request):
|
|
560
|
+
if (err := _check_rate_limit(request)):
|
|
561
|
+
return err
|
|
484
562
|
try:
|
|
485
563
|
from cloudwright.architect import ConversationSession
|
|
486
564
|
|
|
@@ -490,23 +568,98 @@ async def chat(req: ChatRequest):
|
|
|
490
568
|
for msg in req.history:
|
|
491
569
|
session.history.append({"role": msg.role, "content": msg.content})
|
|
492
570
|
|
|
493
|
-
|
|
571
|
+
try:
|
|
572
|
+
text, spec = await asyncio.wait_for(asyncio.to_thread(session.send, req.message), timeout=120)
|
|
573
|
+
except asyncio.TimeoutError:
|
|
574
|
+
return _error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
|
|
575
|
+
|
|
494
576
|
if spec is None and not req.history:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
577
|
+
try:
|
|
578
|
+
spec = await asyncio.wait_for(asyncio.to_thread(architect.design, req.message), timeout=120)
|
|
579
|
+
text = f"Architecture: {spec.name}"
|
|
580
|
+
except asyncio.TimeoutError:
|
|
581
|
+
return _error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
|
|
582
|
+
|
|
583
|
+
result: dict = {
|
|
584
|
+
"reply": text,
|
|
585
|
+
"history": session.history,
|
|
586
|
+
"usage": session.last_usage,
|
|
587
|
+
}
|
|
498
588
|
if spec:
|
|
499
589
|
result["spec"] = spec.model_dump(exclude_none=True)
|
|
500
590
|
result["yaml"] = spec.to_yaml()
|
|
501
591
|
return result
|
|
502
592
|
except RuntimeError as e:
|
|
503
593
|
if "No LLM provider" in str(e):
|
|
504
|
-
|
|
594
|
+
return _error_response("missing_api_key", str(e), "Set an LLM provider API key in your environment", 503)
|
|
505
595
|
log.exception("Chat endpoint failed")
|
|
506
|
-
|
|
507
|
-
except Exception
|
|
596
|
+
return _error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
597
|
+
except Exception:
|
|
508
598
|
log.exception("Chat endpoint failed")
|
|
509
|
-
|
|
599
|
+
return _error_response("internal_error", "Internal server error", "Check server logs for details", 500)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@app.post("/api/chat/stream")
|
|
603
|
+
async def chat_stream(req: ChatRequest, request: Request):
|
|
604
|
+
if (err := _check_rate_limit(request)):
|
|
605
|
+
return err
|
|
606
|
+
|
|
607
|
+
async def event_generator():
|
|
608
|
+
from cloudwright.architect import ConversationSession
|
|
609
|
+
|
|
610
|
+
architect = get_architect()
|
|
611
|
+
session = ConversationSession(llm=architect.llm)
|
|
612
|
+
|
|
613
|
+
for msg in req.history:
|
|
614
|
+
session.history.append({"role": msg.role, "content": msg.content})
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
# Run the streaming iterator in a thread, yielding SSE token events
|
|
618
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
619
|
+
loop = asyncio.get_event_loop()
|
|
620
|
+
|
|
621
|
+
def _run_stream():
|
|
622
|
+
try:
|
|
623
|
+
for chunk in session.send_stream(req.message):
|
|
624
|
+
loop.call_soon_threadsafe(queue.put_nowait, ("token", chunk))
|
|
625
|
+
loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
loop.call_soon_threadsafe(queue.put_nowait, ("error", str(exc)))
|
|
628
|
+
|
|
629
|
+
thread = threading.Thread(target=_run_stream, daemon=True)
|
|
630
|
+
thread.start()
|
|
631
|
+
|
|
632
|
+
deadline = time.time() + 120
|
|
633
|
+
while True:
|
|
634
|
+
remaining = deadline - time.time()
|
|
635
|
+
if remaining <= 0:
|
|
636
|
+
yield f"data: {json.dumps({'stage': 'error', 'code': 'llm_timeout', 'message': 'Request timed out', 'suggestion': 'Try a simpler architecture description'})}\n\n"
|
|
637
|
+
return
|
|
638
|
+
try:
|
|
639
|
+
kind, payload = await asyncio.wait_for(queue.get(), timeout=min(remaining, 5))
|
|
640
|
+
except asyncio.TimeoutError:
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
if kind == "token":
|
|
644
|
+
yield f"data: {json.dumps({'stage': 'token', 'data': payload})}\n\n"
|
|
645
|
+
elif kind == "error":
|
|
646
|
+
yield f"data: {json.dumps({'stage': 'error', 'message': payload})}\n\n"
|
|
647
|
+
return
|
|
648
|
+
else: # done
|
|
649
|
+
spec = session.current_spec
|
|
650
|
+
done_event: dict = {
|
|
651
|
+
"stage": "done",
|
|
652
|
+
"usage": session.last_usage,
|
|
653
|
+
}
|
|
654
|
+
if spec:
|
|
655
|
+
done_event["data"] = spec.model_dump(exclude_none=True)
|
|
656
|
+
done_event["yaml"] = spec.to_yaml()
|
|
657
|
+
yield f"data: {json.dumps(done_event)}\n\n"
|
|
658
|
+
return
|
|
659
|
+
except Exception as e:
|
|
660
|
+
yield f"data: {json.dumps({'stage': 'error', 'message': str(e)})}\n\n"
|
|
661
|
+
|
|
662
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
510
663
|
|
|
511
664
|
|
|
512
665
|
# Serve frontend static files if they exist
|