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/__init__.py
ADDED
|
File without changes
|
api/auth.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi import HTTPException, Request, Security
|
|
10
|
+
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
|
11
|
+
from jose import JWTError, jwt
|
|
12
|
+
|
|
13
|
+
_API_KEY_HEADER = APIKeyHeader(name="X-Wright-API-Key", auto_error=False)
|
|
14
|
+
_BEARER = HTTPBearer(auto_error=False)
|
|
15
|
+
_KEY_FILE = Path(os.getenv("WRIGHT_KEY_FILE", Path.home() / ".wright" / "api.key"))
|
|
16
|
+
|
|
17
|
+
WORKOS_CLIENT_ID = os.getenv("WORKOS_CLIENT_ID", "")
|
|
18
|
+
WORKOS_API_KEY = os.getenv("WORKOS_API_KEY", "")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_or_generate_key() -> str:
|
|
22
|
+
"""
|
|
23
|
+
Loads or generates a WrightAI API key by checking the environment variable, an existing key file, or creating a new cryptographically secure random key.
|
|
24
|
+
|
|
25
|
+
Attempts to retrieve the API key in the following priority order: (1) from the WRIGHT_API_KEY environment variable, (2) from an existing key file at the path defined by _KEY_FILE, or (3) generates a new cryptographically secure random key using secrets.token_urlsafe(32), saves it to the key file with restricted permissions (0o600), creates parent directories with mode 0o700 if needed, and prints a notification message to stderr on first run.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: The WrightAI API key as a URL-safe string, sourced from the WRIGHT_API_KEY environment variable, an existing key file, or a newly generated secure random token.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
```
|
|
32
|
+
api_key = _load_or_generate_key()
|
|
33
|
+
print(api_key) # e.g., 'aB3dEfGhIjKlMnOpQrStUvWxYz0123456789-_Ab'
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
env_key = os.getenv("WRIGHT_API_KEY", "")
|
|
37
|
+
if env_key:
|
|
38
|
+
return env_key
|
|
39
|
+
if _KEY_FILE.exists():
|
|
40
|
+
return _KEY_FILE.read_text().strip()
|
|
41
|
+
_KEY_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
42
|
+
key = secrets.token_urlsafe(32)
|
|
43
|
+
_KEY_FILE.write_text(key)
|
|
44
|
+
_KEY_FILE.chmod(0o600)
|
|
45
|
+
print(
|
|
46
|
+
f"\n WrightAI API key generated (first run).\n"
|
|
47
|
+
f" Saved to: {_KEY_FILE}\n"
|
|
48
|
+
f" Read it with: cat {_KEY_FILE}\n",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
return key
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_WRIGHT_API_KEY = _load_or_generate_key()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_jwks_cache: dict | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_jwks() -> dict:
|
|
61
|
+
"""Fetch and cache the WorkOS JWKS public key set."""
|
|
62
|
+
global _jwks_cache
|
|
63
|
+
if _jwks_cache is None:
|
|
64
|
+
client_id = os.getenv("WORKOS_CLIENT_ID", "")
|
|
65
|
+
resp = httpx.get(
|
|
66
|
+
f"https://api.workos.com/user_management/jwks/{client_id}",
|
|
67
|
+
timeout=5,
|
|
68
|
+
)
|
|
69
|
+
if resp.status_code != 200:
|
|
70
|
+
raise HTTPException(status_code=503, detail="Could not fetch WorkOS JWKS")
|
|
71
|
+
_jwks_cache = resp.json()
|
|
72
|
+
return _jwks_cache
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _verify_workos_token(token: str) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Validates a WorkOS JWT access token by fetching the JWKS public keys and verifying the signature.
|
|
78
|
+
|
|
79
|
+
Fetches the WorkOS JWKS (JSON Web Key Set) for the configured client ID, then decodes and
|
|
80
|
+
verifies the provided JWT against those public keys. Raises HTTP 401 if the token is invalid,
|
|
81
|
+
expired, or the signature doesn't match.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
token (str): The WorkOS JWT access token to validate.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
dict: The decoded JWT payload containing user identity claims.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
HTTPException: Raised with HTTP status code 401 when the token is invalid or expired.
|
|
91
|
+
HTTPException: Raised with HTTP status code 503 when the JWKS endpoint is unreachable.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
jwks = _get_jwks()
|
|
95
|
+
# Extract the key ID from the JWT header to pick the right key from the set
|
|
96
|
+
header = jwt.get_unverified_header(token)
|
|
97
|
+
kid = header.get("kid")
|
|
98
|
+
key = next(
|
|
99
|
+
(k for k in jwks.get("keys", []) if k.get("kid") == kid),
|
|
100
|
+
jwks.get("keys", [None])[0] if jwks.get("keys") else None,
|
|
101
|
+
)
|
|
102
|
+
if key is None:
|
|
103
|
+
raise HTTPException(status_code=401, detail="Invalid WorkOS token: no matching key")
|
|
104
|
+
claims = jwt.decode(
|
|
105
|
+
token,
|
|
106
|
+
key,
|
|
107
|
+
algorithms=["RS256"],
|
|
108
|
+
audience=os.getenv("WORKOS_CLIENT_ID", ""),
|
|
109
|
+
)
|
|
110
|
+
return claims
|
|
111
|
+
except JWTError as exc:
|
|
112
|
+
raise HTTPException(status_code=401, detail=f"Invalid WorkOS token: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def verify_api_key(
|
|
116
|
+
request: Request,
|
|
117
|
+
api_key: str | None = Security(_API_KEY_HEADER),
|
|
118
|
+
bearer: HTTPAuthorizationCredentials | None = Security(_BEARER),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Verifies incoming API authentication credentials against user-issued API keys, a server-level static key, or WorkOS bearer tokens, raising HTTP 401 on any failure.
|
|
122
|
+
|
|
123
|
+
Authenticates requests via one of three ordered methods: (1) user-issued API keys prefixed with 'wai_' are looked up in the Supabase user store via get_user_by_api_key(); (2) a server-level static key is accepted directly for CLI, GitHub Actions, or MCP integrations; (3) a WorkOS bearer token is verified by calling _verify_workos_token(). The function returns silently on the first successful match. If no method succeeds or no credentials are supplied at all, an HTTP 401 exception is raised.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
request (Request): The incoming FastAPI/Starlette HTTP request object, injected by the dependency injection system.
|
|
127
|
+
api_key (str | None): Optional API key extracted from the request headers. Accepts either a user-issued key (prefixed with 'wai_') validated against Supabase, or a server-level static key for CLI/GitHub Actions/MCP.
|
|
128
|
+
bearer (HTTPAuthorizationCredentials | None): Optional bearer token credentials extracted from the Authorization header, used for WorkOS token authentication.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
None: Returns None implicitly on successful authentication; no value is produced.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
HTTPException: Raised with status 401 when an API key with the 'wai_' prefix is not found in the Supabase user store.
|
|
135
|
+
HTTPException: Raised with status 401 when WorkOS bearer token verification fails via _verify_workos_token().
|
|
136
|
+
HTTPException: Raised with status 401 when no valid credentials are provided or all authentication methods fail.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
```
|
|
140
|
+
await verify_api_key(request, api_key='wai_abc123def456', bearer=None)
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
# Accept user API keys issued via WorkOS + Supabase (wai_ prefix)
|
|
144
|
+
if api_key and api_key.startswith("wai_"):
|
|
145
|
+
from api.user_store import get_user_by_api_key
|
|
146
|
+
|
|
147
|
+
if get_user_by_api_key(api_key):
|
|
148
|
+
return
|
|
149
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
150
|
+
|
|
151
|
+
# Accept server-level static key (CLI / GitHub Action / MCP)
|
|
152
|
+
if api_key and api_key == _WRIGHT_API_KEY:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Accept WorkOS Bearer token directly
|
|
156
|
+
if bearer and bearer.credentials:
|
|
157
|
+
try:
|
|
158
|
+
_verify_workos_token(bearer.credentials)
|
|
159
|
+
return
|
|
160
|
+
except HTTPException:
|
|
161
|
+
raise
|
|
162
|
+
except Exception:
|
|
163
|
+
raise HTTPException(status_code=401, detail="Invalid WorkOS token")
|
|
164
|
+
|
|
165
|
+
raise HTTPException(status_code=401, detail="Invalid or missing credentials")
|
api/chroma_cache.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_logger = logging.getLogger("wright.chroma_cache")
|
|
10
|
+
|
|
11
|
+
_cache: dict[str, tuple[object, float]] = {}
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
_TTL = 300 # seconds — accept up to 5 min staleness from cross-container writes
|
|
14
|
+
_restored_paths: set[str] = set() # per persist_path, one-time GCS restore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _restore_chroma_from_gcs(persist_path: str, repo_root: str) -> None:
|
|
18
|
+
"""Copy the per-user/repo GCS backup into persist_path on first access."""
|
|
19
|
+
from api.routes.repos import _parse_repo_root # lazy — avoids circular import
|
|
20
|
+
|
|
21
|
+
dst = Path(persist_path)
|
|
22
|
+
if dst.exists():
|
|
23
|
+
return
|
|
24
|
+
parsed = _parse_repo_root(repo_root)
|
|
25
|
+
src = Path("/data/chroma") / parsed[0] / parsed[1] if parsed else Path("/data/chroma")
|
|
26
|
+
if src.exists():
|
|
27
|
+
try:
|
|
28
|
+
shutil.copytree(str(src), str(dst))
|
|
29
|
+
except Exception as e:
|
|
30
|
+
_logger.warning("ChromaDB GCS restore failed for %s: %s", persist_path, e)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get(persist_path: str, repo_root: str):
|
|
34
|
+
"""Return a cached ChromaStore, creating one if missing or TTL-expired.
|
|
35
|
+
|
|
36
|
+
Opening a PersistentClient on GCS Fuse loads SQLite and the HNSW index from
|
|
37
|
+
the network filesystem — ~1-3 s per cold open. Caching the instance means
|
|
38
|
+
only the first request per (path, repo_root) per container pays that cost;
|
|
39
|
+
subsequent requests reuse the warm in-memory index (~10-50 ms).
|
|
40
|
+
|
|
41
|
+
If the resulting store is empty (fresh /tmp on a cold container, with no
|
|
42
|
+
/data/chroma backup either), it's repopulated from the Supabase pgvector
|
|
43
|
+
backup — the durable source of truth for bidirectional sync.
|
|
44
|
+
"""
|
|
45
|
+
from core.embeddings.chroma_store import ChromaStore
|
|
46
|
+
|
|
47
|
+
key = f"{persist_path}::{repo_root}"
|
|
48
|
+
now = time.monotonic()
|
|
49
|
+
needs_rebuild = False
|
|
50
|
+
needs_restore = False
|
|
51
|
+
with _lock:
|
|
52
|
+
if persist_path not in _restored_paths:
|
|
53
|
+
_restored_paths.add(persist_path)
|
|
54
|
+
needs_restore = True
|
|
55
|
+
if needs_restore:
|
|
56
|
+
_restore_chroma_from_gcs(persist_path, repo_root)
|
|
57
|
+
with _lock:
|
|
58
|
+
if key in _cache:
|
|
59
|
+
store, ts = _cache[key]
|
|
60
|
+
if now - ts < _TTL:
|
|
61
|
+
return store
|
|
62
|
+
store = ChromaStore(persist_path=persist_path, repo_root=repo_root)
|
|
63
|
+
_cache[key] = (store, now)
|
|
64
|
+
needs_rebuild = store.count() == 0
|
|
65
|
+
|
|
66
|
+
if needs_rebuild:
|
|
67
|
+
_rebuild_from_pgvector(store, repo_root)
|
|
68
|
+
return store
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rebuild_from_pgvector(store, repo_root: str) -> None:
|
|
72
|
+
"""Repopulate a freshly-created, empty ChromaStore from its Supabase
|
|
73
|
+
pgvector backup (e.g. on a cold container start with no /data/chroma)."""
|
|
74
|
+
from api.routes.repos import _parse_repo_root # lazy import, avoids circular import
|
|
75
|
+
|
|
76
|
+
parsed = _parse_repo_root(repo_root)
|
|
77
|
+
if parsed is None:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
from core.embeddings.pgvector_store import PgVectorStore
|
|
81
|
+
|
|
82
|
+
chunks, embeddings = PgVectorStore(*parsed).get_all_chunks()
|
|
83
|
+
if chunks:
|
|
84
|
+
store.upsert_chunks(chunks, embeddings)
|
|
85
|
+
_logger.info(
|
|
86
|
+
"Rebuilt local chroma for %s from pgvector (%d chunks)", repo_root, len(chunks)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def invalidate(persist_path: str, repo_root: str) -> None:
|
|
91
|
+
"""Drop the cached entry so the next request reloads from disk.
|
|
92
|
+
|
|
93
|
+
Call this after a successful upsert to force the cache to reflect newly
|
|
94
|
+
written embeddings on the next request.
|
|
95
|
+
"""
|
|
96
|
+
key = f"{persist_path}::{repo_root}"
|
|
97
|
+
with _lock:
|
|
98
|
+
_cache.pop(key, None)
|
api/embedder.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
_embedder = None
|
|
6
|
+
_gateway = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_embedder():
|
|
10
|
+
"""Return the process-level VoyageEmbedder singleton.
|
|
11
|
+
|
|
12
|
+
Creating a new VoyageEmbedder on every request initialises a voyageai.Client
|
|
13
|
+
object each time. This singleton avoids that overhead while keeping the API
|
|
14
|
+
key resolution lazy (read at first call, not at import time).
|
|
15
|
+
"""
|
|
16
|
+
global _embedder
|
|
17
|
+
if _embedder is None:
|
|
18
|
+
from core.embeddings.voyage_embeddings import VoyageEmbedder
|
|
19
|
+
|
|
20
|
+
_embedder = VoyageEmbedder(api_key=os.getenv("VOYAGE_API_KEY", ""))
|
|
21
|
+
return _embedder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_gateway():
|
|
25
|
+
"""Return the process-level LLMGateway singleton.
|
|
26
|
+
|
|
27
|
+
LLMGateway.__init__ creates an anthropic.AsyncAnthropic client on every
|
|
28
|
+
call. Sharing one instance across requests avoids that allocation and keeps
|
|
29
|
+
the underlying httpx connection pool alive between requests.
|
|
30
|
+
"""
|
|
31
|
+
global _gateway
|
|
32
|
+
if _gateway is None:
|
|
33
|
+
from core.llm.gateway import LLMGateway
|
|
34
|
+
|
|
35
|
+
_gateway = LLMGateway(
|
|
36
|
+
anthropic_key=os.getenv("ANTHROPIC_API_KEY", ""),
|
|
37
|
+
gemini_key=os.getenv("GEMINI_API_KEY"),
|
|
38
|
+
)
|
|
39
|
+
return _gateway
|
api/main.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# WrightAI — AI-powered code documentation tool
|
|
2
|
+
# Copyright (C) 2026 Suraj Sahoo
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
# https://github.com/surajs1999/WrightAI
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import uvicorn
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
app = FastAPI(
|
|
20
|
+
title="Wright API",
|
|
21
|
+
version="1.0.0",
|
|
22
|
+
description="AI-powered code documentation API",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from api.observability import setup_observability # noqa: E402
|
|
26
|
+
from api.rate_limit import limiter # noqa: E402
|
|
27
|
+
from slowapi import _rate_limit_exceeded_handler # noqa: E402
|
|
28
|
+
from slowapi.errors import RateLimitExceeded # noqa: E402
|
|
29
|
+
|
|
30
|
+
setup_observability(app)
|
|
31
|
+
app.state.limiter = limiter
|
|
32
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
33
|
+
|
|
34
|
+
_ALLOWED_ORIGINS = [
|
|
35
|
+
o.strip()
|
|
36
|
+
for o in os.getenv(
|
|
37
|
+
"CORS_ORIGINS",
|
|
38
|
+
"http://localhost:3000,https://www.wrightai.live,https://wrightai.live,https://wrightai-web.netlify.app",
|
|
39
|
+
).split(",")
|
|
40
|
+
if o.strip()
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
app.add_middleware(
|
|
44
|
+
CORSMiddleware,
|
|
45
|
+
allow_origins=_ALLOWED_ORIGINS,
|
|
46
|
+
allow_credentials=True,
|
|
47
|
+
allow_methods=["*"],
|
|
48
|
+
allow_headers=["*"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_logger = logging.getLogger("wright.api")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.middleware("http")
|
|
55
|
+
async def log_requests(request: Request, call_next):
|
|
56
|
+
"""
|
|
57
|
+
Logs HTTP request details including method, path, status code, and processing duration.
|
|
58
|
+
|
|
59
|
+
FastAPI middleware that intercepts HTTP requests, measures the time taken to process them, and logs the request method, URL path, response status code, and duration in milliseconds.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
request (Request): The incoming HTTP request object containing request metadata and information.
|
|
63
|
+
call_next (Callable): Middleware chain callable that processes the request and returns the response.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Response: The HTTP response object returned from the next middleware or route handler.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
```
|
|
70
|
+
# Registered automatically via the @app.middleware("http") decorator above.
|
|
71
|
+
# Logs one line per request, e.g.:
|
|
72
|
+
# 2026-01-01T00:00:00 INFO GET /health 200 1.2ms
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Complexity: O(1) time, O(1) space
|
|
76
|
+
"""
|
|
77
|
+
start = time.perf_counter()
|
|
78
|
+
response = await call_next(request)
|
|
79
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
80
|
+
_logger.info(
|
|
81
|
+
"http_request",
|
|
82
|
+
extra={
|
|
83
|
+
"http_method": request.method,
|
|
84
|
+
"http_path": request.url.path,
|
|
85
|
+
"http_status": response.status_code,
|
|
86
|
+
"duration_ms": round(duration_ms, 1),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
from api.routes import ( # noqa: E402
|
|
93
|
+
auth,
|
|
94
|
+
billing,
|
|
95
|
+
chat,
|
|
96
|
+
coverage,
|
|
97
|
+
drift,
|
|
98
|
+
fix_pr,
|
|
99
|
+
generate,
|
|
100
|
+
internal,
|
|
101
|
+
llms_txt,
|
|
102
|
+
repos,
|
|
103
|
+
usage,
|
|
104
|
+
webhooks,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
app.include_router(auth.router)
|
|
108
|
+
app.include_router(billing.router)
|
|
109
|
+
app.include_router(generate.router)
|
|
110
|
+
app.include_router(coverage.router)
|
|
111
|
+
app.include_router(drift.router)
|
|
112
|
+
app.include_router(chat.router)
|
|
113
|
+
app.include_router(repos.router)
|
|
114
|
+
app.include_router(fix_pr.router)
|
|
115
|
+
app.include_router(llms_txt.router)
|
|
116
|
+
app.include_router(usage.router)
|
|
117
|
+
app.include_router(webhooks.router)
|
|
118
|
+
app.include_router(internal.router)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.get("/health")
|
|
122
|
+
async def health() -> dict:
|
|
123
|
+
"""Liveness probe — returns immediately. Used by Cloud Run to detect crashes."""
|
|
124
|
+
return {"status": "ok", "version": "1.0.0"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.get("/ready")
|
|
128
|
+
async def ready() -> JSONResponse:
|
|
129
|
+
"""Readiness probe — checks live dependencies before accepting traffic."""
|
|
130
|
+
checks: dict[str, str] = {}
|
|
131
|
+
|
|
132
|
+
# Supabase reachability
|
|
133
|
+
try:
|
|
134
|
+
from api.user_store import _db
|
|
135
|
+
|
|
136
|
+
_db().table("plans").select("id").limit(1).execute()
|
|
137
|
+
checks["database"] = "ok"
|
|
138
|
+
except Exception:
|
|
139
|
+
checks["database"] = "error"
|
|
140
|
+
|
|
141
|
+
# Required LLM credentials
|
|
142
|
+
checks["anthropic_key"] = "ok" if os.getenv("ANTHROPIC_API_KEY") else "missing"
|
|
143
|
+
checks["voyage_key"] = "ok" if os.getenv("VOYAGE_API_KEY") else "missing"
|
|
144
|
+
|
|
145
|
+
all_ok = all(v == "ok" for v in checks.values())
|
|
146
|
+
return JSONResponse(
|
|
147
|
+
{"status": "ready" if all_ok else "degraded", "checks": checks},
|
|
148
|
+
status_code=200 if all_ok else 503,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
from fastapi import Depends # noqa: E402
|
|
153
|
+
from api.auth import verify_api_key # noqa: E402
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.post("/user/key/rotate", dependencies=[Depends(verify_api_key)])
|
|
157
|
+
async def rotate_key(request: Request) -> dict:
|
|
158
|
+
"""
|
|
159
|
+
Rotates the API key for the authenticated user by invalidating the old key and generating a new one.
|
|
160
|
+
|
|
161
|
+
This FastAPI POST endpoint reads the current API key from the 'X-Wright-API-Key' request header, delegates key rotation to the user store, and returns the newly generated API key. Access is protected by the 'verify_api_key' dependency. If no user is associated with the provided key, a 404 HTTP exception is raised.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
request (Request): The incoming FastAPI HTTP request object, used to extract the current API key from the 'X-Wright-API-Key' header.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
dict: A dictionary containing the newly generated API key under the key 'api_key', e.g., {'api_key': 'new-generated-key-string'}.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
HTTPException: Raised with status code 404 and detail 'User not found' when no user is associated with the provided API key.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
```
|
|
174
|
+
# Using httpx or requests in an async context:
|
|
175
|
+
import httpx
|
|
176
|
+
|
|
177
|
+
response = httpx.post(
|
|
178
|
+
'http://localhost:8000/user/key/rotate',
|
|
179
|
+
headers={'X-Wright-API-Key': 'existing-api-key-abc123'}
|
|
180
|
+
)
|
|
181
|
+
new_key = response.json() # {'api_key': 'newly-rotated-key-xyz789'}
|
|
182
|
+
```
|
|
183
|
+
"""
|
|
184
|
+
from api.user_store import rotate_api_key
|
|
185
|
+
from fastapi import HTTPException
|
|
186
|
+
|
|
187
|
+
old_key = request.headers.get("X-Wright-API-Key", "")
|
|
188
|
+
user = rotate_api_key(old_key)
|
|
189
|
+
if not user:
|
|
190
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
191
|
+
return {"api_key": user.api_key}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.get("/user/me", dependencies=[Depends(verify_api_key)])
|
|
195
|
+
async def user_me(request: Request) -> dict:
|
|
196
|
+
"""
|
|
197
|
+
Retrieves the authenticated user's profile information by looking up the API key from the request headers.
|
|
198
|
+
|
|
199
|
+
An async FastAPI endpoint protected by the verify_api_key dependency. It extracts the X-Wright-API-Key header from the incoming request, resolves the corresponding user via the user store, and returns the user's email, API key, and account creation timestamp. Raises a 401 Unauthorized exception if the key is absent or unrecognized.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
request (Request): The FastAPI Request object used to access HTTP headers, specifically the X-Wright-API-Key header for user lookup.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
dict: A dictionary containing three keys: 'email' (str) — the user's email address, 'api_key' (str) — the user's API key, and 'created_at' (str or datetime) — the ISO 8601 timestamp of account creation.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
HTTPException: Raised with status_code=401 when the X-Wright-API-Key header is missing, empty, or does not correspond to any user in the user store.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
```
|
|
212
|
+
# GET /user/me with Header: X-Wright-API-Key: abc123xyz
|
|
213
|
+
# Response:
|
|
214
|
+
# {
|
|
215
|
+
# "email": "user@example.com",
|
|
216
|
+
# "api_key": "abc123xyz",
|
|
217
|
+
# "created_at": "2024-01-01T00:00:00"
|
|
218
|
+
# }
|
|
219
|
+
```
|
|
220
|
+
"""
|
|
221
|
+
from api.user_store import get_user_by_api_key
|
|
222
|
+
|
|
223
|
+
api_key = request.headers.get("X-Wright-API-Key", "")
|
|
224
|
+
user = get_user_by_api_key(api_key)
|
|
225
|
+
if not user:
|
|
226
|
+
from fastapi import HTTPException
|
|
227
|
+
|
|
228
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
229
|
+
return {
|
|
230
|
+
"email": user.email,
|
|
231
|
+
"api_key": user.api_key,
|
|
232
|
+
"created_at": user.created_at,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def start() -> None:
|
|
237
|
+
"""
|
|
238
|
+
Starts the Uvicorn ASGI server to run the Wright API application on the configured host and port.
|
|
239
|
+
|
|
240
|
+
Initializes and runs the FastAPI application using Uvicorn with the host bound to all network interfaces (0.0.0.0) and the port configured via the WRIGHT_API_PORT environment variable (defaulting to 8765). Auto-reload is disabled for production stability. This function blocks until the server is interrupted.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
None: Does not return a value; runs the server until interrupted.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: When the WRIGHT_API_PORT environment variable is set to a non-integer value that cannot be converted via int().
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
```
|
|
250
|
+
import os
|
|
251
|
+
os.environ['WRIGHT_API_PORT'] = '8765'
|
|
252
|
+
start() # Starts the Wright API server on http://0.0.0.0:8765
|
|
253
|
+
```
|
|
254
|
+
"""
|
|
255
|
+
uvicorn.run(
|
|
256
|
+
"api.main:app",
|
|
257
|
+
host="0.0.0.0",
|
|
258
|
+
port=int(os.getenv("WRIGHT_API_PORT", "8765")),
|
|
259
|
+
reload=False,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
start()
|
api/observability.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability setup: structured JSON logging + Sentry error tracking.
|
|
3
|
+
Call setup_observability(app) once, right after FastAPI() is created.
|
|
4
|
+
|
|
5
|
+
Required env vars:
|
|
6
|
+
SENTRY_DSN — enables Sentry (get from sentry.io → Project Settings → DSN)
|
|
7
|
+
|
|
8
|
+
Optional env vars:
|
|
9
|
+
ENVIRONMENT — "production" / "staging" (default "production")
|
|
10
|
+
K_REVISION — Cloud Run revision tag, used as Sentry release identifier
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger("wright.observability")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def configure_logging() -> None:
|
|
22
|
+
"""Replace the plaintext formatter with JSON so Cloud Logging can parse fields."""
|
|
23
|
+
try:
|
|
24
|
+
from pythonjsonlogger import jsonlogger
|
|
25
|
+
|
|
26
|
+
handler = logging.StreamHandler()
|
|
27
|
+
handler.setFormatter(
|
|
28
|
+
jsonlogger.JsonFormatter(
|
|
29
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
30
|
+
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
|
31
|
+
rename_fields={"levelname": "severity", "asctime": "timestamp"},
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
logging.root.setLevel(logging.INFO)
|
|
35
|
+
logging.root.handlers = []
|
|
36
|
+
logging.root.addHandler(handler)
|
|
37
|
+
except ImportError:
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=logging.INFO,
|
|
40
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
41
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _setup_sentry() -> None:
|
|
46
|
+
dsn = os.getenv("SENTRY_DSN", "")
|
|
47
|
+
if not dsn:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
import sentry_sdk
|
|
51
|
+
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
52
|
+
from sentry_sdk.integrations.logging import LoggingIntegration
|
|
53
|
+
|
|
54
|
+
sentry_sdk.init(
|
|
55
|
+
dsn=dsn,
|
|
56
|
+
integrations=[
|
|
57
|
+
FastApiIntegration(),
|
|
58
|
+
LoggingIntegration(level=logging.ERROR, event_level=logging.ERROR),
|
|
59
|
+
],
|
|
60
|
+
traces_sample_rate=0.0, # no performance tracing — error tracking only
|
|
61
|
+
environment=os.getenv("ENVIRONMENT", "production"),
|
|
62
|
+
release=os.getenv("K_REVISION", "unknown"),
|
|
63
|
+
)
|
|
64
|
+
_logger.info(
|
|
65
|
+
"Sentry initialised", extra={"environment": os.getenv("ENVIRONMENT", "production")}
|
|
66
|
+
)
|
|
67
|
+
except ImportError:
|
|
68
|
+
_logger.warning("sentry-sdk not installed — error tracking disabled")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup_observability(app) -> None: # noqa: ARG001
|
|
72
|
+
"""Wire up structured logging and Sentry. Call once after FastAPI() is created."""
|
|
73
|
+
configure_logging()
|
|
74
|
+
_setup_sentry()
|