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.
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/PKG-INFO +1 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/__init__.py +1 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/app.py +22 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/middleware.py +41 -1
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/chat.py +61 -46
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/design.py +48 -10
- cloudwright_ai_web-1.4.0/cloudwright_web/routers/health.py +102 -0
- cloudwright_ai_web-1.4.0/tests/test_api_key_auth.py +98 -0
- cloudwright_ai_web-1.4.0/tests/test_chat_stream_cancellation.py +190 -0
- cloudwright_ai_web-1.4.0/tests/test_design_returns_usage.py +108 -0
- cloudwright_ai_web-1.4.0/tests/test_design_stream_async.py +150 -0
- cloudwright_ai_web-1.4.0/tests/test_health_endpoint.py +124 -0
- cloudwright_ai_web-1.4.0/tests/test_request_id.py +51 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_streaming_api.py +9 -0
- cloudwright_ai_web-1.2.2/cloudwright_web/routers/health.py +0 -43
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/.gitignore +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/README.md +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/py.typed +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/__init__.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/catalog.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/cost.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/diagram.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/diff.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/export.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/modules.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/routers/validate.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/singletons.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/assets/index-BZV40eAE.css +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/assets/index-DoHV2oTE.js +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/static/index.html +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/cloudwright_web/streaming.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/index.html +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/package-lock.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/package.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/App.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ArchitectureDiagram.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/BoundaryNode.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CatalogDrawer.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CloudServiceNode.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/CostTable.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/DiagramControls.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/DiagramLegend.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ExportPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/NodeSidePanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/SpecPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/SummaryBar.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/components/ValidationPanel.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/lib/icons.ts +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/src/main.tsx +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/tsconfig.json +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/frontend/vite.config.ts +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/pyproject.toml +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/__init__.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/conftest.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_agent_browser.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_api_behavioral.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_diagram_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_error_responses.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_modules_api.py +0 -0
- {cloudwright_ai_web-1.2.2 → cloudwright_ai_web-1.4.0}/tests/test_rate_limiting.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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"
|