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.
Files changed (39) hide show
  1. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/PKG-INFO +1 -1
  2. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/__init__.py +1 -1
  3. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/app.py +175 -22
  4. cloudwright_ai_web-0.3.5/cloudwright_web/static/assets/index-BigRPwDr.js +65 -0
  5. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/index.html +1 -1
  6. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/App.tsx +79 -15
  7. cloudwright_ai_web-0.3.5/tests/test_agent_browser.py +660 -0
  8. cloudwright_ai_web-0.3.5/tests/test_api_behavioral.py +274 -0
  9. cloudwright_ai_web-0.3.5/tests/test_error_responses.py +76 -0
  10. cloudwright_ai_web-0.3.5/tests/test_rate_limiting.py +80 -0
  11. cloudwright_ai_web-0.3.5/tests/test_streaming_api.py +105 -0
  12. cloudwright_ai_web-0.3.5/tests/test_usage_tracking.py +76 -0
  13. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/.gitignore +0 -0
  14. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/README.md +0 -0
  15. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/py.typed +0 -0
  16. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
  17. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/cloudwright_web/static/assets/index-fjOZ8Gop.js +0 -0
  18. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/index.html +0 -0
  19. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/package-lock.json +0 -0
  20. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/package.json +0 -0
  21. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
  22. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/BoundaryNode.tsx +0 -0
  23. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/CloudServiceNode.tsx +0 -0
  24. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/CostTable.tsx +0 -0
  25. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/DiagramControls.tsx +0 -0
  26. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/DiagramLegend.tsx +0 -0
  27. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ExportPanel.tsx +0 -0
  28. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/NodeSidePanel.tsx +0 -0
  29. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/SpecPanel.tsx +0 -0
  30. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/SummaryBar.tsx +0 -0
  31. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/components/ValidationPanel.tsx +0 -0
  32. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/lib/icons.ts +0 -0
  33. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/src/main.tsx +0 -0
  34. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/tsconfig.json +0 -0
  35. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/frontend/vite.config.ts +0 -0
  36. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/pyproject.toml +0 -0
  37. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/tests/__init__.py +0 -0
  38. {cloudwright_ai_web-0.3.2 → cloudwright_ai_web-0.3.5}/tests/test_api.py +0 -0
  39. {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.2
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
@@ -1,6 +1,6 @@
1
1
  """Cloudwright Web — FastAPI backend for architecture intelligence."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.5"
4
4
 
5
5
 
6
6
  def __getattr__(name: str):
@@ -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
- _architect = Architect()
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
- _catalog = Catalog()
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
- _cost_engine = CostEngine()
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
- spec = await asyncio.to_thread(architect.design, req.description, constraints)
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
- raise HTTPException(status_code=503, detail=str(e)) from e
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
- raise HTTPException(status_code=500, detail="Internal server error") from e
221
- except Exception as e:
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
- raise HTTPException(status_code=500, detail="Internal server error") from e
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
- updated = await asyncio.to_thread(architect.modify, spec, req.instruction)
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 as e:
352
+ except Exception:
277
353
  log.exception("Modify endpoint failed")
278
- raise HTTPException(status_code=500, detail="Internal server error") from e
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
- text, spec = await asyncio.to_thread(session.send, req.message)
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
- spec = await asyncio.to_thread(architect.design, req.message)
496
- text = f"Architecture: {spec.name}"
497
- result: dict = {"reply": text, "history": session.history}
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
- raise HTTPException(status_code=503, detail=str(e)) from e
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
- raise HTTPException(status_code=500, detail="Internal server error") from e
507
- except Exception as e:
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
- raise HTTPException(status_code=500, detail="Internal server error") from e
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