dh-cli 0.5.1__tar.gz → 0.6.3__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.
- {dh_cli-0.5.1 → dh_cli-0.6.3}/PKG-INFO +1 -1
- {dh_cli-0.5.1 → dh_cli-0.6.3}/pyproject.toml +1 -1
- dh_cli-0.6.3/src/dh_cli/bedrock/__init__.py +4 -0
- dh_cli-0.6.3/src/dh_cli/bedrock/commands.py +414 -0
- dh_cli-0.6.3/src/dh_cli/bedrock/cost_report.py +505 -0
- dh_cli-0.6.3/src/dh_cli/bedrock/pricing.yaml +28 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/engine_commands.py +89 -102
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/main.py +13 -0
- dh_cli-0.6.3/tests/bedrock/conftest.py +35 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/A_cache_write.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/B_cache_read.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/C_plain.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/D_cursor_user.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/E_service_role.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/F_legacy_shared.json +22 -0
- dh_cli-0.6.3/tests/bedrock/fixtures/G_unknown_model.json +22 -0
- dh_cli-0.6.3/tests/bedrock/test_build_report.py +237 -0
- dh_cli-0.6.3/tests/bedrock/test_classify_arn.py +96 -0
- dh_cli-0.6.3/tests/bedrock/test_cli_exit_codes.py +87 -0
- dh_cli-0.6.3/tests/bedrock/test_cost_calc.py +116 -0
- dh_cli-0.6.3/tests/bedrock/test_cost_command.py +133 -0
- dh_cli-0.6.3/tests/bedrock/test_cur_reconciliation.py +267 -0
- dh_cli-0.6.3/tests/bedrock/test_key_command.py +113 -0
- dh_cli-0.6.3/tests/bedrock/test_render_formats.py +221 -0
- dh_cli-0.6.3/tests/bedrock/test_resolve_base_model.py +43 -0
- dh_cli-0.6.3/tests/bedrock/test_s3_walker.py +162 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/.gitignore +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/LICENSE +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/README.md +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_init.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_users.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/test_finalize_protmpnn.py +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""`dh bedrock` command group.
|
|
2
|
+
|
|
3
|
+
Two user-facing commands:
|
|
4
|
+
|
|
5
|
+
- `dh bedrock key` — fetch the per-developer Cursor Bedrock key from
|
|
6
|
+
Secrets Manager (`cursor-bedrock/<handle>`). Replaces the
|
|
7
|
+
`aws secretsmanager get-secret-value … | jq` one-liner.
|
|
8
|
+
- `dh bedrock cost` — run the per-user Bedrock cost reporter against
|
|
9
|
+
the log bucket. Wraps `cost_report.build_report` with sensible
|
|
10
|
+
defaults so devs don't have to remember bucket/account/region.
|
|
11
|
+
|
|
12
|
+
Both commands default to reading the caller's identity via STS to
|
|
13
|
+
resolve their own handle, so the common case is parameter-free.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from . import cost_report as cr
|
|
25
|
+
|
|
26
|
+
# Hard-coded for the dev account. Kept here (not as user-facing flags)
|
|
27
|
+
# because they never change in practice and making every dev remember
|
|
28
|
+
# them is the whole reason this command exists.
|
|
29
|
+
_DEFAULT_BUCKET = "dayhoff-bedrock-logs-074735440724"
|
|
30
|
+
_DEFAULT_ACCOUNT = "074735440724"
|
|
31
|
+
_DEFAULT_REGION = "us-east-1"
|
|
32
|
+
_SECRET_PREFIX = "cursor-bedrock/"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group()
|
|
36
|
+
def bedrock():
|
|
37
|
+
"""Bedrock key delivery and per-user cost reporting.
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
Commands:
|
|
41
|
+
key Fetch your personal Cursor Bedrock access key from Secrets Manager.
|
|
42
|
+
cost Query per-user Bedrock spend from invocation logs.
|
|
43
|
+
|
|
44
|
+
\b
|
|
45
|
+
Examples:
|
|
46
|
+
dh bedrock key # print your key JSON
|
|
47
|
+
dh bedrock key --export # `export AWS_...` for eval
|
|
48
|
+
dh bedrock cost # last 7 days, grouped by user
|
|
49
|
+
dh bedrock cost --me # just your own spend
|
|
50
|
+
dh bedrock cost --days 30 --group-by model
|
|
51
|
+
dh bedrock cost --format markdown # pipe-table for Slack/docs
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# Shared helpers
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_handle_from_sts() -> str:
|
|
61
|
+
"""Pull the developer handle from the current SSO session.
|
|
62
|
+
|
|
63
|
+
A DeveloperAccess SSO caller identity looks like::
|
|
64
|
+
|
|
65
|
+
arn:aws:sts::074735440724:assumed-role/AWSReservedSSO_DeveloperAccess_<id>/<handle>
|
|
66
|
+
|
|
67
|
+
We return `<handle>`. Raises ClickException if STS fails or the ARN
|
|
68
|
+
shape isn't recognisable.
|
|
69
|
+
"""
|
|
70
|
+
import boto3
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
sts = boto3.client("sts")
|
|
74
|
+
arn = sts.get_caller_identity()["Arn"]
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise click.ClickException(
|
|
77
|
+
f"Could not call sts:GetCallerIdentity to resolve your handle: {exc}\n"
|
|
78
|
+
"Run `awslogin dev-devaccess` (or pass --handle explicitly)."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
principal = cr.classify_arn(arn)
|
|
82
|
+
if principal.principal_type in ("claude-code", "cursor"):
|
|
83
|
+
return principal.principal_name
|
|
84
|
+
raise click.ClickException(
|
|
85
|
+
f"Couldn't infer a developer handle from your identity ({arn}). "
|
|
86
|
+
"Pass --handle explicitly."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# `dh bedrock key`
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@bedrock.command("key")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--handle",
|
|
98
|
+
"-u",
|
|
99
|
+
default=None,
|
|
100
|
+
help="Developer handle (e.g. 'dma'). Defaults to your SSO caller identity.",
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--region",
|
|
104
|
+
default=_DEFAULT_REGION,
|
|
105
|
+
show_default=True,
|
|
106
|
+
help="AWS region for the Secrets Manager secret.",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--export",
|
|
110
|
+
"mode",
|
|
111
|
+
flag_value="export",
|
|
112
|
+
help="Print `export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…` for shell eval.",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--json",
|
|
116
|
+
"mode",
|
|
117
|
+
flag_value="json",
|
|
118
|
+
default=True,
|
|
119
|
+
help="Print the full secret JSON (default).",
|
|
120
|
+
)
|
|
121
|
+
def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
122
|
+
"""Fetch your Cursor Bedrock access key from Secrets Manager.
|
|
123
|
+
|
|
124
|
+
Paste the output into Cursor → Settings → Models → AWS Bedrock.
|
|
125
|
+
|
|
126
|
+
\b
|
|
127
|
+
Examples:
|
|
128
|
+
dh bedrock key
|
|
129
|
+
dh bedrock key --handle jason # pull someone else's key (will 403)
|
|
130
|
+
eval "$(dh bedrock key --export)" # export into current shell
|
|
131
|
+
"""
|
|
132
|
+
import boto3
|
|
133
|
+
|
|
134
|
+
resolved = handle or _resolve_handle_from_sts()
|
|
135
|
+
secret_id = f"{_SECRET_PREFIX}{resolved}"
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
sm = boto3.client("secretsmanager", region_name=region)
|
|
139
|
+
resp = sm.get_secret_value(SecretId=secret_id)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise click.ClickException(
|
|
142
|
+
f"Could not read secret `{secret_id}`: {exc}\n"
|
|
143
|
+
"Check that you're logged in (`awslogin dev-devaccess`), that your "
|
|
144
|
+
"handle matches (use --handle <you>), and that MFA is fresh."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(resp["SecretString"])
|
|
149
|
+
except (KeyError, json.JSONDecodeError) as exc:
|
|
150
|
+
raise click.ClickException(f"Secret `{secret_id}` is not valid JSON: {exc}")
|
|
151
|
+
|
|
152
|
+
if mode == "export":
|
|
153
|
+
ak = payload.get("access_key_id", "")
|
|
154
|
+
sk = payload.get("secret_access_key", "")
|
|
155
|
+
if not ak or not sk:
|
|
156
|
+
raise click.ClickException(
|
|
157
|
+
f"Secret `{secret_id}` is missing access_key_id/secret_access_key fields."
|
|
158
|
+
)
|
|
159
|
+
click.echo(f"export AWS_ACCESS_KEY_ID='{ak}'")
|
|
160
|
+
click.echo(f"export AWS_SECRET_ACCESS_KEY='{sk}'")
|
|
161
|
+
click.echo(f"export AWS_DEFAULT_REGION='{payload.get('region', region)}'")
|
|
162
|
+
else:
|
|
163
|
+
click.echo(json.dumps(payload, indent=2))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
# `dh bedrock cost`
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_date(_ctx, _param, value: Optional[str]) -> Optional[dt.date]:
|
|
172
|
+
if value is None:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
return dt.datetime.strptime(value, "%Y-%m-%d").date()
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
raise click.BadParameter(f"Expected YYYY-MM-DD, got {value!r}: {exc}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@bedrock.command("cost")
|
|
181
|
+
@click.option(
|
|
182
|
+
"--start",
|
|
183
|
+
callback=_parse_date,
|
|
184
|
+
default=None,
|
|
185
|
+
help="Start date, UTC (YYYY-MM-DD, inclusive). Defaults to `--days` ago.",
|
|
186
|
+
)
|
|
187
|
+
@click.option(
|
|
188
|
+
"--end",
|
|
189
|
+
callback=_parse_date,
|
|
190
|
+
default=None,
|
|
191
|
+
help="End date, UTC (YYYY-MM-DD, inclusive). Defaults to today (UTC).",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--days",
|
|
195
|
+
default=7,
|
|
196
|
+
show_default=True,
|
|
197
|
+
type=int,
|
|
198
|
+
help="Look-back window in days when --start is not given.",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--group-by",
|
|
202
|
+
type=click.Choice(sorted(cr._VALID_GROUP_BY)),
|
|
203
|
+
default="user",
|
|
204
|
+
show_default=True,
|
|
205
|
+
help=(
|
|
206
|
+
"Aggregation key. 'user' → top-spender view; 'user+model' → per "
|
|
207
|
+
"person × model; 'model' → Opus-vs-Sonnet split; 'principal_type' "
|
|
208
|
+
"→ Cursor/Claude-Code/service-role split."
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
@click.option(
|
|
212
|
+
"--me",
|
|
213
|
+
is_flag=True,
|
|
214
|
+
default=False,
|
|
215
|
+
help="Only show rows for your own handle (resolved from STS).",
|
|
216
|
+
)
|
|
217
|
+
@click.option(
|
|
218
|
+
"--bucket",
|
|
219
|
+
default=_DEFAULT_BUCKET,
|
|
220
|
+
show_default=True,
|
|
221
|
+
help="S3 bucket holding Bedrock invocation logs.",
|
|
222
|
+
)
|
|
223
|
+
@click.option(
|
|
224
|
+
"--account",
|
|
225
|
+
default=_DEFAULT_ACCOUNT,
|
|
226
|
+
show_default=True,
|
|
227
|
+
help="AWS account ID that owns the logs.",
|
|
228
|
+
)
|
|
229
|
+
@click.option(
|
|
230
|
+
"--region",
|
|
231
|
+
default=_DEFAULT_REGION,
|
|
232
|
+
show_default=True,
|
|
233
|
+
help="AWS region that produced the logs.",
|
|
234
|
+
)
|
|
235
|
+
@click.option(
|
|
236
|
+
"--pricing",
|
|
237
|
+
"pricing_path",
|
|
238
|
+
default=None,
|
|
239
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
240
|
+
help="Override the bundled pricing.yaml.",
|
|
241
|
+
)
|
|
242
|
+
@click.option(
|
|
243
|
+
"--no-reconcile",
|
|
244
|
+
is_flag=True,
|
|
245
|
+
default=False,
|
|
246
|
+
help="Skip the Cost Explorer reconciliation call (faster, no ce:GetCostAndUsage).",
|
|
247
|
+
)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--format",
|
|
250
|
+
"output_format",
|
|
251
|
+
type=click.Choice(["pretty", "markdown", "csv"]),
|
|
252
|
+
default="pretty",
|
|
253
|
+
show_default=True,
|
|
254
|
+
help="pretty = aligned terminal table (default); markdown = pipe-table "
|
|
255
|
+
"for Slack/docs; csv = machine-readable, full column set.",
|
|
256
|
+
)
|
|
257
|
+
def bedrock_cost(
|
|
258
|
+
start: Optional[dt.date],
|
|
259
|
+
end: Optional[dt.date],
|
|
260
|
+
days: int,
|
|
261
|
+
group_by: str,
|
|
262
|
+
me: bool,
|
|
263
|
+
bucket: str,
|
|
264
|
+
account: str,
|
|
265
|
+
region: str,
|
|
266
|
+
pricing_path: Optional[str],
|
|
267
|
+
no_reconcile: bool,
|
|
268
|
+
output_format: str,
|
|
269
|
+
):
|
|
270
|
+
"""Print per-user Bedrock spend from invocation logs.
|
|
271
|
+
|
|
272
|
+
\b
|
|
273
|
+
Examples:
|
|
274
|
+
dh bedrock cost # last 7 days, by user
|
|
275
|
+
dh bedrock cost --me # just your own rows
|
|
276
|
+
dh bedrock cost --days 30 --group-by model # monthly Opus vs Sonnet
|
|
277
|
+
dh bedrock cost --start 2026-04-20 --end 2026-04-25 --group-by user+model
|
|
278
|
+
dh bedrock cost --format markdown > report.md # paste into Slack/docs
|
|
279
|
+
dh bedrock cost --format csv | csvlook # further analysis
|
|
280
|
+
|
|
281
|
+
Exit codes:
|
|
282
|
+
0 success (within 10% of Cost Explorer, or --no-reconcile)
|
|
283
|
+
1 unknown model ID, bad pricing file, or S3 read error
|
|
284
|
+
2 reconciliation drift > 10% AND absolute gap >= $1
|
|
285
|
+
"""
|
|
286
|
+
# Defaults use UTC dates to match the S3 day-partition layout
|
|
287
|
+
# (`…/BedrockModelInvocationLogs/us-east-1/YYYY/MM/DD/`). Using
|
|
288
|
+
# local time here would silently exclude "today so far" from
|
|
289
|
+
# developers in tz < UTC, which is the whole point of running
|
|
290
|
+
# `dh bedrock cost --me` during a workday. Including today is
|
|
291
|
+
# fine because walk_logs happily iterates the current day's
|
|
292
|
+
# S3 prefix as it's populated in near-real-time.
|
|
293
|
+
today_utc = dt.datetime.now(dt.timezone.utc).date()
|
|
294
|
+
if end is None:
|
|
295
|
+
end = today_utc
|
|
296
|
+
if start is None:
|
|
297
|
+
start = end - dt.timedelta(days=days - 1)
|
|
298
|
+
if start > end:
|
|
299
|
+
raise click.BadParameter(
|
|
300
|
+
f"--start ({start}) must be on or before --end ({end})."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
pricing_file = pricing_path or cr.default_pricing_path()
|
|
304
|
+
try:
|
|
305
|
+
pricing = cr.load_pricing(pricing_file)
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
click.echo(f"error: failed to load pricing: {exc}", err=True)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
import boto3
|
|
311
|
+
|
|
312
|
+
s3 = boto3.client("s3")
|
|
313
|
+
|
|
314
|
+
my_handle: Optional[str] = None
|
|
315
|
+
if me:
|
|
316
|
+
my_handle = _resolve_handle_from_sts()
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
records = cr.walk_logs(
|
|
320
|
+
s3,
|
|
321
|
+
bucket=bucket,
|
|
322
|
+
account=account,
|
|
323
|
+
region=region,
|
|
324
|
+
start=start,
|
|
325
|
+
end=end,
|
|
326
|
+
)
|
|
327
|
+
if my_handle is not None:
|
|
328
|
+
# Filter records upstream so --me works for every --group-by
|
|
329
|
+
# mode, including 'model' and 'principal_type' which collapse
|
|
330
|
+
# principal_name to "" in the output rows.
|
|
331
|
+
records = (
|
|
332
|
+
rec for rec in records
|
|
333
|
+
if cr.classify_arn(
|
|
334
|
+
rec.get("identity", {}).get("arn", "")
|
|
335
|
+
).principal_name == my_handle
|
|
336
|
+
)
|
|
337
|
+
report = cr.build_report(records, pricing, group_by=group_by)
|
|
338
|
+
except cr.UnknownModel as exc:
|
|
339
|
+
click.echo(f"error: {exc}", err=True)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
click.echo(f"error: failed to process logs: {exc}", err=True)
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
# Header is only useful for humans — skip in csv mode so the output
|
|
346
|
+
# is a clean csv stream that can be piped.
|
|
347
|
+
if output_format != "csv":
|
|
348
|
+
subject = f" ({my_handle})" if my_handle else ""
|
|
349
|
+
click.echo(f"Bedrock spend {start} → {end} group_by={group_by}{subject}")
|
|
350
|
+
click.echo("")
|
|
351
|
+
|
|
352
|
+
if output_format == "markdown":
|
|
353
|
+
click.echo(cr.render_markdown(report), nl=False)
|
|
354
|
+
elif output_format == "csv":
|
|
355
|
+
click.echo(cr.render_csv(report), nl=False)
|
|
356
|
+
else:
|
|
357
|
+
click.echo(cr.render_pretty(report, group_by=group_by), nl=False)
|
|
358
|
+
|
|
359
|
+
estimate_total = sum(r.estimated_cost_usd for r in report.rows)
|
|
360
|
+
|
|
361
|
+
if no_reconcile:
|
|
362
|
+
sys.exit(0)
|
|
363
|
+
|
|
364
|
+
# Cost Explorer is always at least a few hours behind real-time
|
|
365
|
+
# (sometimes a full day). Reconciling a window that includes today
|
|
366
|
+
# (or a not-yet-closed UTC day) produces misleading "drift" because
|
|
367
|
+
# the S3 logs are near-real-time but CE hasn't posted the matching
|
|
368
|
+
# charges yet. Skip the CE call in that case — the estimate row
|
|
369
|
+
# above is the authoritative answer.
|
|
370
|
+
if end >= today_utc:
|
|
371
|
+
msg = (
|
|
372
|
+
f"\nSkipping Cost Explorer reconcile: window ends {end} (UTC today "
|
|
373
|
+
f"or later); CE lag makes same-day comparison noisy. Re-run with "
|
|
374
|
+
f"`--end {today_utc - dt.timedelta(days=2)}` (or earlier) for a "
|
|
375
|
+
f"meaningful reconcile."
|
|
376
|
+
)
|
|
377
|
+
if output_format == "pretty":
|
|
378
|
+
click.echo(msg)
|
|
379
|
+
else:
|
|
380
|
+
click.echo(msg, err=True)
|
|
381
|
+
sys.exit(0)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
ce_total = cr.fetch_cost_explorer_total(start, end)
|
|
385
|
+
except Exception as exc:
|
|
386
|
+
click.echo(
|
|
387
|
+
f"\nwarning: could not fetch Cost Explorer total: {exc}",
|
|
388
|
+
err=True,
|
|
389
|
+
)
|
|
390
|
+
sys.exit(0)
|
|
391
|
+
|
|
392
|
+
result = cr.reconcile_with_cost_explorer(estimate_total, ce_total)
|
|
393
|
+
# Keep reconcile output minimal in csv/markdown modes so the body
|
|
394
|
+
# of the output stays pipe-friendly — stderr, not stdout.
|
|
395
|
+
stream_err = output_format in ("csv", "markdown")
|
|
396
|
+
delta_pct = (
|
|
397
|
+
f"{result.delta_fraction * 100:.1f}%"
|
|
398
|
+
if result.delta_fraction != float("inf")
|
|
399
|
+
else "n/a"
|
|
400
|
+
)
|
|
401
|
+
status = "OK" if result.ok else "DRIFT"
|
|
402
|
+
reconcile_line = (
|
|
403
|
+
f"\nReconcile: estimate ${estimate_total:,.2f} "
|
|
404
|
+
f"Cost Explorer ${ce_total:,.2f} "
|
|
405
|
+
f"delta {delta_pct} [{status}]"
|
|
406
|
+
)
|
|
407
|
+
click.echo(reconcile_line, err=stream_err)
|
|
408
|
+
# Absolute-dollar floor on the drift exit code: below $1 of discrepancy,
|
|
409
|
+
# ratio drift is noise (a 50% delta on 2 cents vs. 3 cents doesn't
|
|
410
|
+
# mean anything). ReconcileResult still reports ok=False so
|
|
411
|
+
# programmatic callers can see the state.
|
|
412
|
+
if not result.ok and abs(ce_total - estimate_total) >= 1.00:
|
|
413
|
+
sys.exit(2)
|
|
414
|
+
sys.exit(0)
|