cloudwright-ai-web 1.2.2__tar.gz → 1.4.0__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 (62) hide show
  1. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/PKG-INFO +1 -1
  2. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/__init__.py +1 -1
  3. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/app.py +22 -0
  4. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/middleware.py +41 -1
  5. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/chat.py +61 -46
  6. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/design.py +48 -10
  7. cloudwright_ai_web-1.4.0/cloudwright_web/routers/health.py +102 -0
  8. cloudwright_ai_web-1.4.0/tests/test_api_key_auth.py +98 -0
  9. cloudwright_ai_web-1.4.0/tests/test_chat_stream_cancellation.py +190 -0
  10. cloudwright_ai_web-1.4.0/tests/test_design_returns_usage.py +108 -0
  11. cloudwright_ai_web-1.4.0/tests/test_design_stream_async.py +150 -0
  12. cloudwright_ai_web-1.4.0/tests/test_health_endpoint.py +124 -0
  13. cloudwright_ai_web-1.4.0/tests/test_request_id.py +51 -0
  14. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_streaming_api.py +9 -0
  15. cloudwright_ai_web-1.2.2/cloudwright_web/routers/health.py +0 -43
  16. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/.gitignore +0 -0
  17. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/README.md +0 -0
  18. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/py.typed +0 -0
  19. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/__init__.py +0 -0
  20. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/catalog.py +0 -0
  21. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/cost.py +0 -0
  22. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/diagram.py +0 -0
  23. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/diff.py +0 -0
  24. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/export.py +0 -0
  25. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/modules.py +0 -0
  26. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/validate.py +0 -0
  27. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/singletons.py +0 -0
  28. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
  29. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/assets/index-DoHV2oTE.js +0 -0
  30. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/index.html +0 -0
  31. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/streaming.py +0 -0
  32. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/index.html +0 -0
  33. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/package-lock.json +0 -0
  34. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/package.json +0 -0
  35. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/App.tsx +0 -0
  36. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
  37. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/BoundaryNode.tsx +0 -0
  38. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CatalogDrawer.tsx +0 -0
  39. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CloudServiceNode.tsx +0 -0
  40. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CostTable.tsx +0 -0
  41. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/DiagramControls.tsx +0 -0
  42. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/DiagramLegend.tsx +0 -0
  43. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ExportPanel.tsx +0 -0
  44. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/NodeSidePanel.tsx +0 -0
  45. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/SpecPanel.tsx +0 -0
  46. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/SummaryBar.tsx +0 -0
  47. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ValidationPanel.tsx +0 -0
  48. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/lib/icons.ts +0 -0
  49. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/main.tsx +0 -0
  50. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/tsconfig.json +0 -0
  51. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/vite.config.ts +0 -0
  52. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/pyproject.toml +0 -0
  53. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/__init__.py +0 -0
  54. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/conftest.py +0 -0
  55. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_agent_browser.py +0 -0
  56. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_api.py +0 -0
  57. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_api_behavioral.py +0 -0
  58. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_diagram_api.py +0 -0
  59. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_error_responses.py +0 -0
  60. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_modules_api.py +0 -0
  61. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_rate_limiting.py +0 -0
  62. {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_usage_tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudwright-ai-web
3
- Version: 1.2.2
3
+ Version: 1.4.0
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__ = "1.2.2"
3
+ __version__ = "1.4.0"
4
4
 
5
5
 
6
6
  def __getattr__(name: str):
@@ -14,6 +14,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
14
14
  from cloudwright_web import __version__
15
15
  from cloudwright_web.middleware import ( # noqa: F401
16
16
  PathTraversalMiddleware,
17
+ RequestIdMiddleware,
17
18
  _rate_limiter,
18
19
  _RateLimiter,
19
20
  add_cors,
@@ -44,16 +45,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
44
45
  return response
45
46
 
46
47
 
48
+ def _docs_enabled() -> bool:
49
+ """Whether to expose /docs, /redoc, and /openapi.json.
50
+
51
+ Disabled by default in production (CLOUDWRIGHT_ENV=production) to reduce
52
+ reconnaissance surface. Override with CLOUDWRIGHT_DOCS_ENABLED=true.
53
+ """
54
+ explicit = os.environ.get("CLOUDWRIGHT_DOCS_ENABLED")
55
+ if explicit is not None:
56
+ return explicit.lower() == "true"
57
+ env = os.environ.get("CLOUDWRIGHT_ENV", "").lower()
58
+ return env != "production"
59
+
60
+
47
61
  def create_app() -> FastAPI:
62
+ docs_on = _docs_enabled()
48
63
  application = FastAPI(
49
64
  title="Cloudwright",
50
65
  version=__version__,
51
66
  description="Architecture intelligence for cloud engineers",
67
+ docs_url="/docs" if docs_on else None,
68
+ redoc_url="/redoc" if docs_on else None,
69
+ openapi_url="/openapi.json" if docs_on else None,
52
70
  )
53
71
 
72
+ # RequestIdMiddleware MUST be added LAST so Starlette dispatches it FIRST
73
+ # (Starlette runs middleware in reverse-add order). This way every later
74
+ # middleware's log lines carry the request_id.
54
75
  application.add_middleware(SecurityHeadersMiddleware)
55
76
  application.add_middleware(PathTraversalMiddleware)
56
77
  add_cors(application)
78
+ application.add_middleware(RequestIdMiddleware)
57
79
 
58
80
  application.include_router(health_router, prefix="/api")
59
81
  application.include_router(design_router, prefix="/api")
@@ -2,12 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hmac
5
6
  import os
6
7
  import threading
7
8
  import time
9
+ import uuid
8
10
  from collections import deque
9
11
  from urllib.parse import unquote
10
12
 
13
+ import structlog
11
14
  from fastapi import HTTPException, Request
12
15
  from fastapi.middleware.cors import CORSMiddleware
13
16
  from fastapi.responses import JSONResponse
@@ -22,6 +25,32 @@ class PathTraversalMiddleware(BaseHTTPMiddleware):
22
25
  return await call_next(request)
23
26
 
24
27
 
28
+ class RequestIdMiddleware(BaseHTTPMiddleware):
29
+ """Attach a request correlation ID to every request.
30
+
31
+ - Reads X-Request-Id from incoming headers, otherwise mints a UUID4 hex.
32
+ - Binds the value into structlog's contextvars for the duration of the
33
+ request so every log line carries it.
34
+ - Echoes the same value back as the X-Request-Id response header.
35
+ """
36
+
37
+ async def dispatch(self, request: Request, call_next):
38
+ incoming = request.headers.get("x-request-id", "").strip()
39
+ request_id = incoming or uuid.uuid4().hex
40
+
41
+ # Stash on request.state so handlers can read it if they need to.
42
+ request.state.request_id = request_id
43
+
44
+ structlog.contextvars.bind_contextvars(request_id=request_id)
45
+ try:
46
+ response = await call_next(request)
47
+ finally:
48
+ structlog.contextvars.unbind_contextvars("request_id")
49
+
50
+ response.headers["X-Request-Id"] = request_id
51
+ return response
52
+
53
+
25
54
  def add_cors(app):
26
55
  origins = os.environ.get("CLOUDWRIGHT_CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
27
56
  app.add_middleware(
@@ -38,10 +67,21 @@ _API_KEY = os.environ.get("CLOUDWRIGHT_API_KEY")
38
67
 
39
68
 
40
69
  def check_api_key(request: Request):
70
+ """Validate the X-API-Key header in constant time.
71
+
72
+ Using ``hmac.compare_digest`` prevents timing-based recovery of the
73
+ configured key. Both sides are encoded to bytes (utf-8); mismatched
74
+ lengths are rejected by ``compare_digest`` itself but we short-circuit
75
+ empty input first to avoid leaking even a length signal.
76
+ """
41
77
  if not _API_KEY:
42
78
  return None
43
79
  provided = request.headers.get("x-api-key", "")
44
- if provided != _API_KEY:
80
+ if not provided:
81
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
82
+ expected_bytes = _API_KEY.encode("utf-8")
83
+ provided_bytes = provided.encode("utf-8")
84
+ if not hmac.compare_digest(provided_bytes, expected_bytes):
45
85
  raise HTTPException(status_code=401, detail="Invalid or missing API key")
46
86
  return None
47
87
 
@@ -4,8 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
- import threading
8
- import time
9
7
  from typing import Literal
10
8
 
11
9
  from cloudwright.session import ConversationSession
@@ -20,6 +18,19 @@ from cloudwright_web.streaming import sse_event
20
18
  log = logging.getLogger(__name__)
21
19
  router = APIRouter()
22
20
 
21
+ # Per-call SDK timeout passed into the async LLM client. Matches the previous
22
+ # route-level ``asyncio.wait_for(..., timeout=120)`` budget but pushes the
23
+ # enforcement down into the SDK so cancellation actually short-circuits the
24
+ # upstream LLM call (sync paths used to keep billing tokens after the route
25
+ # timed out).
26
+ _LLM_STREAM_TIMEOUT_S = 120.0
27
+
28
+ # Headers attached to every SSE response. ``X-Accel-Buffering: no`` disables
29
+ # nginx (and most reverse-proxy) buffering so token chunks reach the browser
30
+ # as soon as we yield them — without it, first-token latency observed by the
31
+ # user can balloon to 2-4s waiting for the proxy buffer to fill.
32
+ _SSE_HEADERS = {"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}
33
+
23
34
 
24
35
  class ChatMessage(BaseModel):
25
36
  role: Literal["user", "assistant"]
@@ -91,51 +102,55 @@ async def chat_stream(req: ChatRequest, request: Request):
91
102
  continue # only accept user-role messages from client history
92
103
  session.history.append({"role": msg.role, "content": msg.content})
93
104
 
105
+ # Push the stream timeout into the SDK call; on the async path this
106
+ # cancels the upstream httpx connection, so we stop billing tokens the
107
+ # moment the deadline passes (the old thread-bridge could not).
94
108
  try:
95
- queue: asyncio.Queue = asyncio.Queue(maxsize=256)
96
- loop = asyncio.get_running_loop()
97
-
98
- def _run_stream():
99
- try:
100
- for chunk in session.send_stream(req.message):
101
- loop.call_soon_threadsafe(queue.put_nowait, ("token", chunk))
102
- loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
103
- except Exception as exc:
104
- loop.call_soon_threadsafe(queue.put_nowait, ("error", str(exc)))
105
-
106
- thread = threading.Thread(target=_run_stream, daemon=True)
107
- thread.start()
108
-
109
- deadline = time.time() + 120
110
- while True:
111
- remaining = deadline - time.time()
112
- if remaining <= 0:
113
- yield sse_event(
114
- "error",
115
- code="llm_timeout",
116
- message="Request timed out",
117
- suggestion="Try a simpler architecture description",
118
- )
119
- return
120
- try:
121
- kind, payload = await asyncio.wait_for(queue.get(), timeout=min(remaining, 5))
122
- except asyncio.TimeoutError:
123
- continue
124
-
125
- if kind == "token":
126
- yield sse_event("token", data=payload)
127
- elif kind == "error":
128
- yield sse_event("error", message=payload)
129
- return
130
- else: # done
131
- spec = session.current_spec
132
- done_kwargs: dict = {"usage": session.last_usage}
133
- if spec:
134
- done_kwargs["data"] = spec.model_dump(exclude_none=True)
135
- done_kwargs["yaml"] = spec.to_yaml()
136
- yield sse_event("done", **done_kwargs)
137
- return
109
+ session.llm.timeout = _LLM_STREAM_TIMEOUT_S # best-effort hint for legacy adapters
110
+ except Exception:
111
+ pass
112
+
113
+ try:
114
+ agen = session.send_stream_async(req.message)
115
+ try:
116
+ while True:
117
+ try:
118
+ chunk = await asyncio.wait_for(agen.__anext__(), timeout=_LLM_STREAM_TIMEOUT_S)
119
+ except StopAsyncIteration:
120
+ break
121
+ except asyncio.TimeoutError:
122
+ # ``aclose`` cancels the inner ``async for`` and the
123
+ # SDK's ``async with`` tears down the upstream connection,
124
+ # so we do not orphan a coroutine billing tokens.
125
+ await agen.aclose()
126
+ yield sse_event(
127
+ "error",
128
+ code="llm_timeout",
129
+ message="Request timed out",
130
+ suggestion="Try a simpler architecture description",
131
+ )
132
+ return
133
+ yield sse_event("token", data=chunk)
134
+ finally:
135
+ # If the consumer disconnects (or anything else aborts the
136
+ # generator), explicitly close the inner async-gen so the
137
+ # SDK's ``async with`` cleanup runs synchronously here rather
138
+ # than at GC time.
139
+ await agen.aclose()
140
+
141
+ spec = session.current_spec
142
+ done_kwargs: dict = {"usage": session.last_usage}
143
+ if spec:
144
+ done_kwargs["data"] = spec.model_dump(exclude_none=True)
145
+ done_kwargs["yaml"] = spec.to_yaml()
146
+ yield sse_event("done", **done_kwargs)
147
+ except asyncio.CancelledError:
148
+ # Re-raised so Starlette can finish the request unwind. We do NOT
149
+ # emit an SSE error event here — the consumer is gone, nobody is
150
+ # listening, and yielding into a closed connection raises again.
151
+ raise
138
152
  except Exception as e:
153
+ log.exception("Chat stream failed")
139
154
  yield sse_event("error", message=str(e))
140
155
 
141
- return StreamingResponse(event_generator(), media_type="text/event-stream")
156
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_SSE_HEADERS)
@@ -17,6 +17,12 @@ from cloudwright_web.streaming import sse_event
17
17
  log = logging.getLogger(__name__)
18
18
  router = APIRouter()
19
19
 
20
+ # Mirrors the chat router. Push timeouts into ``asyncio.wait_for`` so a
21
+ # cancelled request actually unwinds the SDK call instead of leaving a worker
22
+ # thread to keep billing tokens.
23
+ _LLM_DESIGN_TIMEOUT_S = 120.0
24
+ _SSE_HEADERS = {"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}
25
+
20
26
 
21
27
  class DesignRequest(BaseModel):
22
28
  description: str = Field(..., min_length=5, max_length=2000)
@@ -52,7 +58,8 @@ async def design(req: DesignRequest, request: Request):
52
58
  spec = spec.model_copy(update={"cost_estimate": cost_estimate})
53
59
  except Exception:
54
60
  log.warning("Cost estimation failed in design endpoint", exc_info=True)
55
- return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml()}
61
+ usage = getattr(architect, "last_usage", None) or {}
62
+ return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml(), "usage": usage}
56
63
  except RuntimeError as e:
57
64
  if "No LLM provider" in str(e):
58
65
  return error_response("missing_api_key", str(e), "Set an LLM provider API key in your environment", 503)
@@ -78,12 +85,30 @@ async def design_stream(req: DesignRequest, request: Request):
78
85
  yield sse_event("generating", message="Generating architecture...")
79
86
 
80
87
  try:
81
- spec = await asyncio.to_thread(architect.design, req.description, constraints)
88
+ # ``asyncio.wait_for`` cancels the awaitable on timeout, which
89
+ # propagates ``CancelledError`` into the ``to_thread`` shim. The
90
+ # underlying ``architect.design`` is still a sync SDK call, so it
91
+ # may finish anyway, but the route no longer blocks forever
92
+ # waiting on it. Once ``Architect`` exposes an async path this
93
+ # becomes truly cancel-safe.
94
+ spec = await asyncio.wait_for(
95
+ asyncio.to_thread(architect.design, req.description, constraints),
96
+ timeout=_LLM_DESIGN_TIMEOUT_S,
97
+ )
98
+ except asyncio.TimeoutError:
99
+ yield sse_event(
100
+ "error",
101
+ code="llm_timeout",
102
+ message="Request timed out",
103
+ suggestion="Try a simpler architecture description",
104
+ )
105
+ return
82
106
  except Exception as e:
83
107
  yield sse_event("error", message=str(e))
84
108
  return
85
109
 
86
- yield sse_event("generated", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml())
110
+ usage = getattr(architect, "last_usage", None) or {}
111
+ yield sse_event("generated", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
87
112
 
88
113
  yield sse_event("costing", message="Estimating cost...")
89
114
  try:
@@ -107,9 +132,9 @@ async def design_stream(req: DesignRequest, request: Request):
107
132
  log.warning("Validation failed in design stream", exc_info=True)
108
133
  yield sse_event("validated", passed=None, total=None)
109
134
 
110
- yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml())
135
+ yield sse_event("done", spec=spec.model_dump(exclude_none=True), yaml=spec.to_yaml(), usage=usage)
111
136
 
112
- return StreamingResponse(event_generator(), media_type="text/event-stream")
137
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_SSE_HEADERS)
113
138
 
114
139
 
115
140
  @router.post("/modify")
@@ -124,7 +149,8 @@ async def modify(req: ModifyRequest, request: Request):
124
149
  updated = await asyncio.wait_for(asyncio.to_thread(architect.modify, spec, req.instruction), timeout=120)
125
150
  except asyncio.TimeoutError:
126
151
  return error_response("llm_timeout", "Request timed out", "Try a simpler architecture description", 504)
127
- return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml()}
152
+ usage = getattr(architect, "last_usage", None) or {}
153
+ return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml(), "usage": usage}
128
154
  except Exception:
129
155
  log.exception("Modify endpoint failed")
130
156
  return error_response("internal_error", "Internal server error", "Check server logs for details", 500)
@@ -143,12 +169,24 @@ async def modify_stream(req: ModifyRequest, request: Request):
143
169
  yield sse_event("modifying", message="Applying modifications...")
144
170
 
145
171
  try:
146
- updated = await asyncio.to_thread(architect.modify, spec, req.instruction)
172
+ updated = await asyncio.wait_for(
173
+ asyncio.to_thread(architect.modify, spec, req.instruction),
174
+ timeout=_LLM_DESIGN_TIMEOUT_S,
175
+ )
176
+ except asyncio.TimeoutError:
177
+ yield sse_event(
178
+ "error",
179
+ code="llm_timeout",
180
+ message="Request timed out",
181
+ suggestion="Try a simpler modification",
182
+ )
183
+ return
147
184
  except Exception as e:
148
185
  yield sse_event("error", message=str(e))
149
186
  return
150
187
 
151
- yield sse_event("modified", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml())
188
+ usage = getattr(architect, "last_usage", None) or {}
189
+ yield sse_event("modified", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
152
190
 
153
191
  yield sse_event("costing", message="Estimating cost...")
154
192
  try:
@@ -159,6 +197,6 @@ async def modify_stream(req: ModifyRequest, request: Request):
159
197
  log.warning("Cost estimation failed in modify stream", exc_info=True)
160
198
  yield sse_event("costed", cost_estimate=None)
161
199
 
162
- yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml())
200
+ yield sse_event("done", spec=updated.model_dump(exclude_none=True), yaml=updated.to_yaml(), usage=usage)
163
201
 
164
- return StreamingResponse(event_generator(), media_type="text/event-stream")
202
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_SSE_HEADERS)
@@ -0,0 +1,102 @@
1
+ """GET /api/health, /api/version, and static icon serving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+
12
+ import cloudwright_web.singletons as _singletons
13
+
14
+ router = APIRouter()
15
+
16
+ # Captured at module import for uptime reporting.
17
+ _START_MONOTONIC = time.monotonic()
18
+
19
+
20
+ def _llm_provider_and_model() -> tuple[str | None, str | None]:
21
+ """Return (provider, model_name) based on env, without instantiating clients."""
22
+ if os.environ.get("ANTHROPIC_API_KEY"):
23
+ from cloudwright.llm.anthropic import GENERATE_MODEL as ANTHROPIC_MODEL
24
+
25
+ return "anthropic", ANTHROPIC_MODEL
26
+ if os.environ.get("OPENAI_API_KEY"):
27
+ from cloudwright.llm.openai import GENERATE_MODEL as OPENAI_MODEL
28
+
29
+ return "openai", OPENAI_MODEL
30
+ return None, None
31
+
32
+
33
+ def _version_payload() -> dict:
34
+ from cloudwright import __version__
35
+
36
+ provider, model = _llm_provider_and_model()
37
+ return {
38
+ "version": __version__,
39
+ "build_sha": os.environ.get("CLOUDWRIGHT_BUILD_SHA"),
40
+ "llm_provider": provider,
41
+ "llm_model": model,
42
+ }
43
+
44
+
45
+ @router.get("/health")
46
+ def health():
47
+ has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
48
+ if not has_llm_key:
49
+ return JSONResponse(
50
+ status_code=503,
51
+ content={
52
+ "status": "degraded",
53
+ "reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)",
54
+ **_version_payload(),
55
+ "catalog_loaded": False,
56
+ "catalog_size": 0,
57
+ "uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
58
+ },
59
+ )
60
+
61
+ catalog_loaded = False
62
+ catalog_size = 0
63
+ try:
64
+ catalog = _singletons.get_catalog()
65
+ # Try to size the catalog; fall back to a sample search if no len.
66
+ try:
67
+ catalog_size = len(catalog) # type: ignore[arg-type]
68
+ except TypeError:
69
+ catalog_size = len(catalog.search(query="m5", limit=1))
70
+ catalog_loaded = True
71
+ except Exception:
72
+ catalog_loaded = False
73
+
74
+ body = {
75
+ "status": "ok" if catalog_loaded else "degraded",
76
+ **_version_payload(),
77
+ "catalog_loaded": catalog_loaded,
78
+ "catalog_size": catalog_size,
79
+ "uptime_s": round(time.monotonic() - _START_MONOTONIC, 3),
80
+ }
81
+ if not catalog_loaded:
82
+ # Readiness probes (Kubernetes) should treat catalog-load failure as Not Ready.
83
+ return JSONResponse(status_code=503, content=body)
84
+ return body
85
+
86
+
87
+ @router.get("/version")
88
+ def version():
89
+ return _version_payload()
90
+
91
+
92
+ @router.get("/icons/{provider}/{service}.svg")
93
+ def get_icon(provider: str, service: str):
94
+ import cloudwright
95
+
96
+ icons_dir = Path(cloudwright.__file__).parent / "data" / "icons"
97
+ icon_path = icons_dir / provider / f"{service}.svg"
98
+ if not icon_path.exists():
99
+ raise HTTPException(status_code=404, detail=f"Icon not found: {provider}/{service}")
100
+ if not icon_path.resolve().is_relative_to(icons_dir.resolve()):
101
+ raise HTTPException(status_code=404, detail="Invalid path")
102
+ return FileResponse(str(icon_path), media_type="image/svg+xml")
@@ -0,0 +1,98 @@
1
+ """Tests for the constant-time API key check.
2
+
3
+ The pre-v1.3 ``check_api_key`` used ``provided != _API_KEY`` which leaks the
4
+ key length and (under some interpreters) prefix-match timing. v1.3 switches
5
+ to ``hmac.compare_digest`` on bytes, plus an explicit empty-input short-circuit.
6
+
7
+ Tests patch the module-level ``_API_KEY`` directly via monkeypatch and restore
8
+ it on teardown — we avoid ``importlib.reload`` because reloading the
9
+ middleware module wipes the singleton ``_rate_limiter`` and breaks
10
+ unrelated tests that rely on the same instance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import cloudwright_web.middleware as middleware
16
+ import pytest
17
+ from fastapi import HTTPException
18
+
19
+
20
+ class _StubRequest:
21
+ """Just enough of starlette.Request to drive ``check_api_key``."""
22
+
23
+ def __init__(self, header_value: str | None):
24
+ self.headers = {"x-api-key": header_value} if header_value is not None else {}
25
+
26
+
27
+ @pytest.fixture
28
+ def with_key(monkeypatch):
29
+ """Set the configured API key for the duration of one test."""
30
+
31
+ def _set(value: str | None):
32
+ monkeypatch.setattr(middleware, "_API_KEY", value)
33
+
34
+ return _set
35
+
36
+
37
+ def test_returns_none_when_no_api_key_configured(with_key):
38
+ with_key(None)
39
+ # Even an explicitly garbage header is allowed when no key is set.
40
+ assert middleware.check_api_key(_StubRequest("anything")) is None
41
+
42
+
43
+ def test_correct_key_passes(with_key):
44
+ with_key("secret-token-abc123")
45
+ assert middleware.check_api_key(_StubRequest("secret-token-abc123")) is None
46
+
47
+
48
+ def test_wrong_key_same_length_raises_401(with_key):
49
+ """Two equal-length keys must compare via hmac.compare_digest."""
50
+ with_key("secret-token-abc123")
51
+ with pytest.raises(HTTPException) as exc:
52
+ middleware.check_api_key(_StubRequest("secret-token-XYZ999"))
53
+ assert exc.value.status_code == 401
54
+
55
+
56
+ def test_wrong_key_different_length_raises_401(with_key):
57
+ with_key("secret-token-abc123")
58
+ with pytest.raises(HTTPException) as exc:
59
+ middleware.check_api_key(_StubRequest("short"))
60
+ assert exc.value.status_code == 401
61
+
62
+
63
+ def test_missing_header_raises_401(with_key):
64
+ with_key("secret-token-abc123")
65
+ with pytest.raises(HTTPException) as exc:
66
+ middleware.check_api_key(_StubRequest(None))
67
+ assert exc.value.status_code == 401
68
+
69
+
70
+ def test_empty_header_raises_401(with_key):
71
+ with_key("secret-token-abc123")
72
+ with pytest.raises(HTTPException) as exc:
73
+ middleware.check_api_key(_StubRequest(""))
74
+ assert exc.value.status_code == 401
75
+
76
+
77
+ def test_uses_hmac_compare_digest(with_key, monkeypatch):
78
+ """Sanity check: the implementation actually calls hmac.compare_digest.
79
+
80
+ We monkeypatch ``hmac.compare_digest`` and assert it is invoked with the
81
+ UTF-8 encoded bytes of both sides — this pins the constant-time semantic.
82
+ """
83
+ with_key("abc")
84
+ calls: list[tuple[bytes, bytes]] = []
85
+ real = middleware.hmac.compare_digest
86
+
87
+ def spy(a, b):
88
+ calls.append((a, b))
89
+ return real(a, b)
90
+
91
+ monkeypatch.setattr(middleware.hmac, "compare_digest", spy)
92
+ with pytest.raises(HTTPException):
93
+ middleware.check_api_key(_StubRequest("xyz"))
94
+ assert calls, "hmac.compare_digest should have been called"
95
+ a, b = calls[0]
96
+ assert isinstance(a, bytes) and isinstance(b, bytes)
97
+ assert a == b"xyz"
98
+ assert b == b"abc"