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.
Files changed (76) hide show
  1. {dh_cli-0.5.1 → dh_cli-0.6.3}/PKG-INFO +1 -1
  2. {dh_cli-0.5.1 → dh_cli-0.6.3}/pyproject.toml +1 -1
  3. dh_cli-0.6.3/src/dh_cli/bedrock/__init__.py +4 -0
  4. dh_cli-0.6.3/src/dh_cli/bedrock/commands.py +414 -0
  5. dh_cli-0.6.3/src/dh_cli/bedrock/cost_report.py +505 -0
  6. dh_cli-0.6.3/src/dh_cli/bedrock/pricing.yaml +28 -0
  7. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/engine_commands.py +89 -102
  8. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/main.py +13 -0
  9. dh_cli-0.6.3/tests/bedrock/conftest.py +35 -0
  10. dh_cli-0.6.3/tests/bedrock/fixtures/A_cache_write.json +22 -0
  11. dh_cli-0.6.3/tests/bedrock/fixtures/B_cache_read.json +22 -0
  12. dh_cli-0.6.3/tests/bedrock/fixtures/C_plain.json +22 -0
  13. dh_cli-0.6.3/tests/bedrock/fixtures/D_cursor_user.json +22 -0
  14. dh_cli-0.6.3/tests/bedrock/fixtures/E_service_role.json +22 -0
  15. dh_cli-0.6.3/tests/bedrock/fixtures/F_legacy_shared.json +22 -0
  16. dh_cli-0.6.3/tests/bedrock/fixtures/G_unknown_model.json +22 -0
  17. dh_cli-0.6.3/tests/bedrock/test_build_report.py +237 -0
  18. dh_cli-0.6.3/tests/bedrock/test_classify_arn.py +96 -0
  19. dh_cli-0.6.3/tests/bedrock/test_cli_exit_codes.py +87 -0
  20. dh_cli-0.6.3/tests/bedrock/test_cost_calc.py +116 -0
  21. dh_cli-0.6.3/tests/bedrock/test_cost_command.py +133 -0
  22. dh_cli-0.6.3/tests/bedrock/test_cur_reconciliation.py +267 -0
  23. dh_cli-0.6.3/tests/bedrock/test_key_command.py +113 -0
  24. dh_cli-0.6.3/tests/bedrock/test_render_formats.py +221 -0
  25. dh_cli-0.6.3/tests/bedrock/test_resolve_base_model.py +43 -0
  26. dh_cli-0.6.3/tests/bedrock/test_s3_walker.py +162 -0
  27. {dh_cli-0.5.1 → dh_cli-0.6.3}/.gitignore +0 -0
  28. {dh_cli-0.5.1 → dh_cli-0.6.3}/LICENSE +0 -0
  29. {dh_cli-0.5.1 → dh_cli-0.6.3}/README.md +0 -0
  30. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/__init__.py +0 -0
  31. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/__init__.py +0 -0
  32. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/aws_batch.py +0 -0
  33. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/__init__.py +0 -0
  34. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/boltz.py +0 -0
  35. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/cancel.py +0 -0
  36. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/clean.py +0 -0
  37. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  38. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/finalize.py +0 -0
  39. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  40. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/local.py +0 -0
  41. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/logs.py +0 -0
  42. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/orca.py +0 -0
  43. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  44. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  45. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/retry.py +0 -0
  46. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/status.py +0 -0
  47. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/submit.py +0 -0
  48. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/train.py +0 -0
  49. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/commands/wait_for.py +0 -0
  50. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/fasta_utils.py +0 -0
  51. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/h5_utils.py +0 -0
  52. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/job_id.py +0 -0
  53. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/manifest.py +0 -0
  54. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/batch/s3_transport.py +0 -0
  55. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/cloud_commands.py +0 -0
  56. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/codeartifact.py +0 -0
  57. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/__init__.py +0 -0
  58. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/api_client.py +0 -0
  59. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/auth.py +0 -0
  60. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/progress.py +0 -0
  61. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  62. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  63. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/github_commands.py +0 -0
  64. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/__init__.py +0 -0
  65. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/deploy.py +0 -0
  66. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/local.py +0 -0
  67. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/test.py +0 -0
  68. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/tf.py +0 -0
  69. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/hz/users.py +0 -0
  70. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/utility_commands.py +0 -0
  71. {dh_cli-0.5.1 → dh_cli-0.6.3}/src/dh_cli/warehouse.py +0 -0
  72. {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_init.py +0 -0
  73. {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_suites.py +0 -0
  74. {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/hz/test_users.py +0 -0
  75. {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/test_cloud_gcp.py +0 -0
  76. {dh_cli-0.5.1 → dh_cli-0.6.3}/tests/test_finalize_protmpnn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.5.1
3
+ Version: 0.6.3
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.5.1"
7
+ version = "0.6.3"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -0,0 +1,4 @@
1
+ """`dh bedrock` command group — key delivery + per-user cost reporting."""
2
+ from .commands import bedrock
3
+
4
+ __all__ = ["bedrock"]
@@ -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)