cloudcost-cli 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.
@@ -0,0 +1,197 @@
1
+ import base64
2
+ import time
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import httpx
7
+ import jwt
8
+
9
+ from backend.app.comments import CLOUDCOST_COMMENT_MARKER
10
+ from backend.app.config import Settings
11
+
12
+
13
+ def _raise_for_github_status(response: httpx.Response) -> None:
14
+ try:
15
+ response.raise_for_status()
16
+ except httpx.HTTPStatusError as exc:
17
+ detail = response.text.strip() or response.reason_phrase
18
+ accepted_permissions = response.headers.get("x-accepted-github-permissions")
19
+ permission_hint = f" Accepted permissions: {accepted_permissions}." if accepted_permissions else ""
20
+ raise RuntimeError(
21
+ f"GitHub API returned {response.status_code} for "
22
+ f"{response.request.method} {response.request.url}: {detail[:1000]}{permission_hint}"
23
+ ) from exc
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PullRequestContext:
28
+ owner: str
29
+ repo: str
30
+ full_name: str
31
+ number: int
32
+ title: str
33
+ head_sha: str
34
+ installation_id: int
35
+ is_draft: bool
36
+
37
+ @classmethod
38
+ def from_payload(cls, payload: dict[str, Any]) -> "PullRequestContext":
39
+ repository = payload["repository"]
40
+ pull_request = payload["pull_request"]
41
+ return cls(
42
+ owner=repository["owner"]["login"],
43
+ repo=repository["name"],
44
+ full_name=repository["full_name"],
45
+ number=int(pull_request["number"]),
46
+ title=pull_request.get("title") or f"PR #{pull_request['number']}",
47
+ head_sha=pull_request["head"]["sha"],
48
+ installation_id=int(payload["installation"]["id"]),
49
+ is_draft=bool(pull_request.get("draft")),
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PlanFile:
55
+ path: str
56
+ sha: str
57
+ size: int | None = None
58
+
59
+
60
+ class GitHubAppClient:
61
+ def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
62
+ self.settings = settings
63
+ self.client = client or httpx.AsyncClient(timeout=30.0)
64
+
65
+ def _headers(self, token: str) -> dict[str, str]:
66
+ return {
67
+ "Accept": "application/vnd.github+json",
68
+ "Authorization": f"Bearer {token}",
69
+ "X-GitHub-Api-Version": self.settings.github_api_version,
70
+ }
71
+
72
+ def app_jwt(self) -> str:
73
+ now = int(time.time())
74
+ payload = {
75
+ "iat": now - 60,
76
+ "exp": now + (9 * 60),
77
+ "iss": self.settings.github_jwt_issuer,
78
+ }
79
+ return jwt.encode(payload, self.settings.load_github_private_key(), algorithm="RS256")
80
+
81
+ async def create_installation_token(self, installation_id: int) -> str:
82
+ url = f"{self.settings.github_api_url}/app/installations/{installation_id}/access_tokens"
83
+ response = await self.client.post(url, headers=self._headers(self.app_jwt()))
84
+ _raise_for_github_status(response)
85
+ return response.json()["token"]
86
+
87
+
88
+ class GitHubInstallationClient:
89
+ def __init__(self, settings: Settings, token: str, client: httpx.AsyncClient | None = None) -> None:
90
+ self.settings = settings
91
+ self.token = token
92
+ self.client = client or httpx.AsyncClient(timeout=30.0)
93
+
94
+ @property
95
+ def headers(self) -> dict[str, str]:
96
+ return {
97
+ "Accept": "application/vnd.github+json",
98
+ "Authorization": f"Bearer {self.token}",
99
+ "X-GitHub-Api-Version": self.settings.github_api_version,
100
+ }
101
+
102
+ async def list_pull_files(self, owner: str, repo: str, pull_number: int) -> list[dict[str, Any]]:
103
+ files: list[dict[str, Any]] = []
104
+ page = 1
105
+ while page <= 30:
106
+ url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/pulls/{pull_number}/files"
107
+ response = await self.client.get(
108
+ url,
109
+ headers=self.headers,
110
+ params={"per_page": 100, "page": page},
111
+ )
112
+ _raise_for_github_status(response)
113
+ batch = response.json()
114
+ files.extend(batch)
115
+ if len(batch) < 100:
116
+ break
117
+ page += 1
118
+ return files
119
+
120
+ async def fetch_blob(self, owner: str, repo: str, sha: str) -> bytes:
121
+ url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/blobs/{sha}"
122
+ response = await self.client.get(url, headers=self.headers)
123
+ _raise_for_github_status(response)
124
+ payload = response.json()
125
+ if payload.get("encoding") != "base64":
126
+ raise RuntimeError(f"Unsupported GitHub blob encoding: {payload.get('encoding')}")
127
+ compact = "".join(str(payload["content"]).splitlines())
128
+ return base64.b64decode(compact)
129
+
130
+ async def list_repository_tree_files(self, owner: str, repo: str, commit_sha: str) -> list[dict[str, Any]]:
131
+ commit_url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/commits/{commit_sha}"
132
+ commit_response = await self.client.get(commit_url, headers=self.headers)
133
+ _raise_for_github_status(commit_response)
134
+ tree_sha = commit_response.json()["tree"]["sha"]
135
+
136
+ tree_url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/trees/{tree_sha}"
137
+ tree_response = await self.client.get(
138
+ tree_url,
139
+ headers=self.headers,
140
+ params={"recursive": "1"},
141
+ )
142
+ _raise_for_github_status(tree_response)
143
+ tree = tree_response.json()
144
+ return [
145
+ {"filename": item["path"], "sha": item["sha"], "changes": item.get("size")}
146
+ for item in tree.get("tree", [])
147
+ if item.get("type") == "blob"
148
+ ]
149
+
150
+ async def list_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[dict[str, Any]]:
151
+ url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments"
152
+ response = await self.client.get(url, headers=self.headers, params={"per_page": 100})
153
+ _raise_for_github_status(response)
154
+ return response.json()
155
+
156
+ async def upsert_pr_comment(self, owner: str, repo: str, pull_number: int, body: str) -> dict[str, Any]:
157
+ comments = await self.list_issue_comments(owner, repo, pull_number)
158
+ existing = next(
159
+ (comment for comment in comments if CLOUDCOST_COMMENT_MARKER in str(comment.get("body", ""))),
160
+ None,
161
+ )
162
+
163
+ if existing:
164
+ url = existing["url"]
165
+ response = await self.client.patch(url, headers=self.headers, json={"body": body})
166
+ else:
167
+ url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/issues/{pull_number}/comments"
168
+ response = await self.client.post(url, headers=self.headers, json={"body": body})
169
+
170
+ _raise_for_github_status(response)
171
+ return response.json()
172
+
173
+
174
+ def find_terraform_plan_file(files: list[dict[str, Any]], settings: Settings) -> PlanFile | None:
175
+ candidates: list[PlanFile] = []
176
+ for file in files:
177
+ path = str(file.get("filename", ""))
178
+ name = path.rsplit("/", 1)[-1].lower()
179
+ lowered_path = path.lower()
180
+ if name in settings.plan_names or lowered_path.endswith(settings.plan_suffixes):
181
+ sha = file.get("sha")
182
+ if sha:
183
+ candidates.append(PlanFile(path=path, sha=str(sha), size=file.get("changes")))
184
+
185
+ if not candidates:
186
+ return None
187
+
188
+ def priority(item: PlanFile) -> tuple[int, str]:
189
+ name = item.path.rsplit("/", 1)[-1].lower()
190
+ if name == "plan.json":
191
+ return (0, item.path)
192
+ if name in settings.plan_names:
193
+ return (1, item.path)
194
+ return (2, item.path)
195
+
196
+ candidates.sort(key=priority)
197
+ return candidates[0]
@@ -0,0 +1,129 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from backend.app.config import Settings
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class InfracostEstimate:
16
+ raw: dict[str, Any]
17
+ diff_total_monthly_cost: Any
18
+ total_monthly_cost: Any
19
+ past_total_monthly_cost: Any
20
+
21
+
22
+ class InfracostClient:
23
+ def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
24
+ self.settings = settings
25
+ self.client = client or httpx.AsyncClient(timeout=60.0)
26
+
27
+ async def estimate_plan_json(self, plan_json: bytes, filename: str) -> InfracostEstimate:
28
+ mode = self.settings.infracost_mode.strip().lower()
29
+ if mode == "api":
30
+ return await self._estimate_plan_json_api(plan_json, filename)
31
+ if mode == "cli":
32
+ return await self._estimate_plan_json_cli(plan_json, filename)
33
+ raise RuntimeError("Set INFRACOST_MODE to either 'cli' or 'api'.")
34
+
35
+ async def _estimate_plan_json_api(self, plan_json: bytes, filename: str) -> InfracostEstimate:
36
+ response = await self.client.post(
37
+ self.settings.infracost_api_url,
38
+ headers={"x-api-key": self.settings.require_infracost_api_key()},
39
+ data={
40
+ "format": "json",
41
+ "no-color": "true",
42
+ "ci-platform": self.settings.infracost_ci_platform,
43
+ },
44
+ files={
45
+ "path": (filename, plan_json, "application/json"),
46
+ },
47
+ )
48
+ response.raise_for_status()
49
+ payload = response.json()
50
+ return self._estimate_from_payload(payload)
51
+
52
+ async def _estimate_plan_json_cli(self, plan_json: bytes, filename: str) -> InfracostEstimate:
53
+ with tempfile.TemporaryDirectory(prefix="cloudcost-infracost-") as temp_dir:
54
+ safe_name = Path(filename).name or "plan.json"
55
+ plan_path = Path(temp_dir) / safe_name
56
+ output_path = Path(temp_dir) / "infracost.json"
57
+ plan_path.write_bytes(plan_json)
58
+
59
+ env = self._cli_environment()
60
+ command = [
61
+ self.settings.infracost_cli_path,
62
+ "breakdown",
63
+ "--path",
64
+ str(plan_path),
65
+ "--format",
66
+ "json",
67
+ "--out-file",
68
+ str(output_path),
69
+ ]
70
+
71
+ try:
72
+ process = await asyncio.create_subprocess_exec(
73
+ *command,
74
+ stdout=asyncio.subprocess.PIPE,
75
+ stderr=asyncio.subprocess.PIPE,
76
+ env=env,
77
+ )
78
+ except FileNotFoundError as exc:
79
+ raise RuntimeError(
80
+ "Infracost CLI was not found. Install it and set INFRACOST_CLI_PATH if it is not on PATH."
81
+ ) from exc
82
+
83
+ try:
84
+ stdout, stderr = await asyncio.wait_for(
85
+ process.communicate(),
86
+ timeout=self.settings.infracost_timeout_seconds,
87
+ )
88
+ except asyncio.TimeoutError as exc:
89
+ process.kill()
90
+ await process.communicate()
91
+ raise RuntimeError(
92
+ f"Infracost CLI timed out after {self.settings.infracost_timeout_seconds} seconds."
93
+ ) from exc
94
+
95
+ if process.returncode != 0:
96
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
97
+ stdout_text = stdout.decode("utf-8", errors="replace").strip()
98
+ detail = stderr_text or stdout_text or f"exit code {process.returncode}"
99
+ raise RuntimeError(f"Infracost CLI failed: {detail[:2000]}")
100
+
101
+ if output_path.exists():
102
+ payload = json.loads(output_path.read_text(encoding="utf-8"))
103
+ else:
104
+ stdout_text = stdout.decode("utf-8", errors="replace").strip()
105
+ if not stdout_text:
106
+ raise RuntimeError("Infracost CLI did not produce JSON output.")
107
+ payload = json.loads(stdout_text)
108
+
109
+ return self._estimate_from_payload(payload)
110
+
111
+ def _cli_environment(self) -> dict[str, str]:
112
+ env = os.environ.copy()
113
+ env["INFRACOST_CI_PLATFORM"] = self.settings.infracost_ci_platform
114
+ env["INFRACOST_SKIP_UPDATE_CHECK"] = "true"
115
+ env["INFRACOST_LOG_LEVEL"] = "warn"
116
+ env["DISABLE_TELEMETRY"] = "true"
117
+ if self.settings.infracost_api_key:
118
+ env["INFRACOST_API_KEY"] = self.settings.infracost_api_key
119
+ if self.settings.infracost_pricing_api_endpoint:
120
+ env["INFRACOST_PRICING_API_ENDPOINT"] = self.settings.infracost_pricing_api_endpoint
121
+ return env
122
+
123
+ def _estimate_from_payload(self, payload: dict[str, Any]) -> InfracostEstimate:
124
+ return InfracostEstimate(
125
+ raw=payload,
126
+ diff_total_monthly_cost=payload.get("diffTotalMonthlyCost"),
127
+ total_monthly_cost=payload.get("totalMonthlyCost"),
128
+ past_total_monthly_cost=payload.get("pastTotalMonthlyCost"),
129
+ )
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+ from pydantic import BaseModel, Field
5
+
6
+ from backend.app.config import Settings
7
+
8
+
9
+ class VirtualKeyRequest(BaseModel):
10
+ user_id: str | None = None
11
+ team_id: str | None = None
12
+ key_alias: str | None = None
13
+ models: list[str] = Field(default_factory=list)
14
+ max_budget: float | None = None
15
+ budget_duration: str | None = "30d"
16
+ tpm_limit: int | None = None
17
+ rpm_limit: int | None = None
18
+ metadata: dict[str, Any] = Field(default_factory=dict)
19
+
20
+
21
+ class LiteLLMAdminClient:
22
+ def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
23
+ self.settings = settings
24
+ self.client = client or httpx.AsyncClient(timeout=30.0)
25
+
26
+ @property
27
+ def headers(self) -> dict[str, str]:
28
+ return {
29
+ "Authorization": f"Bearer {self.settings.require_litellm_master_key()}",
30
+ "Content-Type": "application/json",
31
+ }
32
+
33
+ async def create_virtual_key(self, request: VirtualKeyRequest) -> dict[str, Any]:
34
+ body = request.model_dump(exclude_none=True)
35
+ response = await self.client.post(
36
+ f"{self.settings.litellm_base_url.rstrip('/')}/key/generate",
37
+ headers=self.headers,
38
+ json=body,
39
+ )
40
+ response.raise_for_status()
41
+ return response.json()