commoncompute 0.3.1__tar.gz → 0.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.
- {commoncompute-0.3.1 → commoncompute-0.4.0}/.gitignore +3 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/PKG-INFO +1 -1
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/__init__.py +3 -1
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_async_client.py +15 -0
- commoncompute-0.4.0/commoncompute/billing.py +124 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/cli.py +205 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/client.py +26 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/LICENSE +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/README.md +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_async_tasks.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_config.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_generated_workloads.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_transport.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/aws_batch.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/compat/__init__.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/compat/openai.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/connect.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/errors.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/models.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/py.typed +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/tasks.py +0 -0
- {commoncompute-0.3.1 → commoncompute-0.4.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commoncompute
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python SDK for Common Compute — the batch AI bill you shouldn't be paying.
|
|
5
5
|
Project-URL: Homepage, https://commoncompute.ai
|
|
6
6
|
Project-URL: Documentation, https://commoncompute.ai/docs
|
|
@@ -30,7 +30,7 @@ in the catalog — the API declines them with `workload_not_available`
|
|
|
30
30
|
# `commoncompute.__version__` at import time for the User-Agent header,
|
|
31
31
|
# and the previous ordering (`__version__` defined at the bottom) made
|
|
32
32
|
# every clean install of the SDK fail with a circular ImportError.
|
|
33
|
-
__version__ = "0.
|
|
33
|
+
__version__ = "0.4.0"
|
|
34
34
|
|
|
35
35
|
from .client import Client, AsyncClient
|
|
36
36
|
from .errors import (
|
|
@@ -51,6 +51,7 @@ from .errors import (
|
|
|
51
51
|
)
|
|
52
52
|
from .models import Job, Quote, Receipt, TaskResult, AccountTier, SpendSummary
|
|
53
53
|
from .connect import connect, login
|
|
54
|
+
from .billing import add_card
|
|
54
55
|
from . import aws_batch # noqa: E402 (intentional re-export)
|
|
55
56
|
|
|
56
57
|
__all__ = [
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"AsyncClient",
|
|
59
60
|
"connect",
|
|
60
61
|
"login",
|
|
62
|
+
"add_card",
|
|
61
63
|
"CommonComputeError",
|
|
62
64
|
"AuthenticationError",
|
|
63
65
|
"PermissionDeniedError",
|
|
@@ -297,6 +297,21 @@ class _BillingAsync:
|
|
|
297
297
|
async def balance(self) -> dict[str, Any]:
|
|
298
298
|
return await self._t.request("GET", "/v1/billing/balance")
|
|
299
299
|
|
|
300
|
+
async def credits(self) -> dict[str, Any]:
|
|
301
|
+
"""Prepaid credit balance, held amounts, and auto-reload settings —
|
|
302
|
+
``GET /v1/billing/credits``. Mirrors ``Client.billing.credits()``."""
|
|
303
|
+
return await self._t.request("GET", "/v1/billing/credits")
|
|
304
|
+
|
|
305
|
+
async def buy_credits(self, *, amount_cents: int, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
306
|
+
"""Charge the saved card for credits. Mirrors ``Client.billing.buy_credits()``
|
|
307
|
+
— needs an API key carrying the ``billing`` scope."""
|
|
308
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
309
|
+
return await self._t.request(
|
|
310
|
+
"POST", "/v1/billing/credits",
|
|
311
|
+
json_body={"amount_cents": amount_cents},
|
|
312
|
+
headers=headers,
|
|
313
|
+
)
|
|
314
|
+
|
|
300
315
|
async def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
301
316
|
return (await self._t.request("GET", "/v1/billing/history", params={"limit": limit})).get("entries", [])
|
|
302
317
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Attach a payment card from a script or notebook.
|
|
2
|
+
|
|
3
|
+
``commoncompute.add_card()`` opens the dashboard billing page, waits for the
|
|
4
|
+
card to actually land server-side, and returns the resulting payment-method
|
|
5
|
+
summary::
|
|
6
|
+
|
|
7
|
+
import commoncompute as cc
|
|
8
|
+
cc.connect() # one-time: get a key
|
|
9
|
+
cc.add_card() # one-time: attach a card
|
|
10
|
+
cc.Client().submit(...)
|
|
11
|
+
|
|
12
|
+
WHY THE BROWSER. The card number has to reach Stripe directly. Accepting it
|
|
13
|
+
here would put the number in this process's memory, its traceback frames, and
|
|
14
|
+
whatever notebook checkpoint happens to capture the cell — and would drag the
|
|
15
|
+
user's machine into PCI scope for no benefit at all. So this function never
|
|
16
|
+
sees a card; it opens the page where Stripe's own iframe collects one.
|
|
17
|
+
|
|
18
|
+
WHY IT POLLS. The card is saved by the ``setup_intent.succeeded`` webhook, not
|
|
19
|
+
by the browser form submitting. Returning as soon as the browser opens would
|
|
20
|
+
report success for a card that may never arrive. Polling the read-only summary
|
|
21
|
+
means a returned value is the server agreeing the card exists.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import webbrowser
|
|
29
|
+
from typing import Any, Callable, Optional
|
|
30
|
+
|
|
31
|
+
from .errors import CommonComputeError
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.commoncompute.ai"
|
|
34
|
+
POLL_INTERVAL_S = 3.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dashboard_url_for(api_base: str, path: str = "/app/billing") -> str:
|
|
38
|
+
"""Map an API base URL to the matching dashboard URL.
|
|
39
|
+
|
|
40
|
+
`api.` is stripped because that is the only difference between the two
|
|
41
|
+
hostnames in every environment we run, so a staging key opens the staging
|
|
42
|
+
dashboard rather than production's. CC_DASHBOARD_URL overrides outright,
|
|
43
|
+
for local dev where the two aren't related by prefix at all.
|
|
44
|
+
"""
|
|
45
|
+
override = os.environ.get("CC_DASHBOARD_URL")
|
|
46
|
+
if override:
|
|
47
|
+
return f"{override.rstrip('/')}{path}"
|
|
48
|
+
return f"{api_base.rstrip('/').replace('://api.', '://', 1)}{path}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_card(
|
|
52
|
+
*,
|
|
53
|
+
client: Any = None,
|
|
54
|
+
open_browser: bool = True,
|
|
55
|
+
timeout: float = 600.0,
|
|
56
|
+
quiet: bool = False,
|
|
57
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Attach a payment card to the account this machine is logged in as.
|
|
60
|
+
|
|
61
|
+
Opens the browser to the dashboard billing page (or prints the URL when
|
|
62
|
+
``open_browser=False``), then blocks until the saved card appears.
|
|
63
|
+
|
|
64
|
+
Returns the payment-method summary — ``{"has_card": True, "brand": ...,
|
|
65
|
+
"last4": ...}``. Returns immediately if a card is already on file, or if
|
|
66
|
+
the account is billing-exempt and doesn't need one.
|
|
67
|
+
|
|
68
|
+
Raises :class:`CommonComputeError` if no card appears before ``timeout``.
|
|
69
|
+
"""
|
|
70
|
+
from .client import Client # local import: avoids a cycle at module load
|
|
71
|
+
|
|
72
|
+
c = client or Client()
|
|
73
|
+
|
|
74
|
+
def say(msg: str) -> None:
|
|
75
|
+
if not quiet:
|
|
76
|
+
print(msg)
|
|
77
|
+
|
|
78
|
+
summary = c.billing.balance()
|
|
79
|
+
if summary.get("has_card"):
|
|
80
|
+
say(f"Card already on file — {describe_card(summary)}")
|
|
81
|
+
return summary
|
|
82
|
+
if summary.get("billing_exempt"):
|
|
83
|
+
say("This account is billing-exempt — no card needed.")
|
|
84
|
+
return summary
|
|
85
|
+
|
|
86
|
+
url = dashboard_url_for(c._transport._base_url)
|
|
87
|
+
say("Add your card in the browser — it goes straight to Stripe, not through this process.")
|
|
88
|
+
say(f" Open: {url}")
|
|
89
|
+
if open_browser:
|
|
90
|
+
try:
|
|
91
|
+
webbrowser.open(url)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # headless; the printed URL is the fallback
|
|
94
|
+
|
|
95
|
+
deadline = time.time() + timeout
|
|
96
|
+
while time.time() < deadline:
|
|
97
|
+
_sleep(POLL_INTERVAL_S)
|
|
98
|
+
try:
|
|
99
|
+
summary = c.billing.balance()
|
|
100
|
+
except Exception:
|
|
101
|
+
continue # transient — the user is mid-flow; keep waiting
|
|
102
|
+
if summary.get("has_card"):
|
|
103
|
+
say(f"✓ Card saved — {describe_card(summary)}")
|
|
104
|
+
return summary
|
|
105
|
+
|
|
106
|
+
raise CommonComputeError(
|
|
107
|
+
f"No card appeared within {timeout:.0f}s. If you did add one, it may still be "
|
|
108
|
+
"processing — check client.billing.balance()."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def describe_card(summary: dict[str, Any]) -> str:
|
|
113
|
+
"""Human label for a payment-method summary.
|
|
114
|
+
|
|
115
|
+
Distinguishes "no card" from "card whose details Stripe couldn't be asked
|
|
116
|
+
for" — the API degrades to has_card without brand/last4 when Stripe is
|
|
117
|
+
unreachable, and calling that "none" would contradict the submit gate.
|
|
118
|
+
"""
|
|
119
|
+
if summary.get("last4"):
|
|
120
|
+
brand = (summary.get("brand") or "card").capitalize()
|
|
121
|
+
return f"{brand} ••••{summary['last4']}"
|
|
122
|
+
if summary.get("has_card"):
|
|
123
|
+
return "on file (details unavailable)"
|
|
124
|
+
return "none"
|
|
@@ -56,10 +56,14 @@ jobs_app = typer.Typer(no_args_is_help=True, help="Inspect and submit jobs.")
|
|
|
56
56
|
keys_app = typer.Typer(no_args_is_help=True, help="Manage API keys.")
|
|
57
57
|
receipts_app = typer.Typer(no_args_is_help=True, help="List, get, and export job receipts.")
|
|
58
58
|
config_app = typer.Typer(no_args_is_help=True, help="Local CLI/SDK configuration (base_url, org, credentials).")
|
|
59
|
+
billing_app = typer.Typer(no_args_is_help=True, help="Payment method and credit balance.")
|
|
60
|
+
credits_app = typer.Typer(no_args_is_help=True, help="Buy and inspect prepaid credits.")
|
|
59
61
|
app.add_typer(jobs_app, name="jobs")
|
|
60
62
|
app.add_typer(keys_app, name="keys")
|
|
61
63
|
app.add_typer(receipts_app, name="receipts")
|
|
62
64
|
app.add_typer(config_app, name="config")
|
|
65
|
+
app.add_typer(billing_app, name="billing")
|
|
66
|
+
app.add_typer(credits_app, name="credits")
|
|
63
67
|
|
|
64
68
|
console = Console()
|
|
65
69
|
err_console = Console(stderr=True, style="red")
|
|
@@ -112,6 +116,39 @@ def _fmt_usd(cents: int) -> str:
|
|
|
112
116
|
return f"${cents / 100:,.2f}"
|
|
113
117
|
|
|
114
118
|
|
|
119
|
+
def _stdin_is_tty() -> bool:
|
|
120
|
+
"""Is there a human who can answer a prompt?
|
|
121
|
+
|
|
122
|
+
Its own function so a test can drive both branches — CliRunner replaces
|
|
123
|
+
sys.stdin, so patching sys.stdin.isatty directly doesn't reach the code
|
|
124
|
+
under test, and the untested branch here is the one that charges a card.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
return sys.stdin.isatty()
|
|
128
|
+
except Exception:
|
|
129
|
+
return False # detached stdin — treat as non-interactive
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _dashboard_url(client: Client, path: str) -> str:
|
|
133
|
+
"""Map the API base URL to the matching dashboard URL.
|
|
134
|
+
|
|
135
|
+
The card itself is entered in a browser, never here — Stripe must receive
|
|
136
|
+
the number directly, and routing it through this process would drag the
|
|
137
|
+
user's machine into PCI scope for no benefit. So the CLI's job is to open
|
|
138
|
+
the right page and then watch for the result.
|
|
139
|
+
|
|
140
|
+
Derived from the transport's base_url rather than hardcoded so `config set
|
|
141
|
+
base_url` and CC_BASE_URL keep pointing at the matching dashboard: staging
|
|
142
|
+
API → staging dashboard. `api.` is stripped because that is the only
|
|
143
|
+
difference between the two hostnames in every environment we run.
|
|
144
|
+
"""
|
|
145
|
+
override = os.environ.get("CC_DASHBOARD_URL")
|
|
146
|
+
base = (override or client._transport._base_url).rstrip("/")
|
|
147
|
+
if not override:
|
|
148
|
+
base = base.replace("://api.", "://", 1)
|
|
149
|
+
return f"{base}{path}"
|
|
150
|
+
|
|
151
|
+
|
|
115
152
|
def _print_err(e: Exception) -> None:
|
|
116
153
|
err_console.print(f"[bold red]Error:[/bold red] {e}")
|
|
117
154
|
|
|
@@ -267,6 +304,174 @@ def balance(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.
|
|
|
267
304
|
console.print(table)
|
|
268
305
|
|
|
269
306
|
|
|
307
|
+
# ─── billing ────────────────────────────────────────────────────────────
|
|
308
|
+
#
|
|
309
|
+
# The card number never passes through this process. `billing add-card` opens
|
|
310
|
+
# the dashboard, where Stripe's own iframe collects it, and then polls a
|
|
311
|
+
# read-only endpoint until the saved card shows up. That split is why these
|
|
312
|
+
# commands need no new API surface and no new permission: GET
|
|
313
|
+
# /v1/billing/balance and /v1/billing/credits already accept an API key
|
|
314
|
+
# (`requireAuth`), while everything that *mutates* billing stays JWT-only and
|
|
315
|
+
# happens in the browser session.
|
|
316
|
+
|
|
317
|
+
@billing_app.command("status")
|
|
318
|
+
def billing_status(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.")) -> None:
|
|
319
|
+
"""Show the saved card and credit balance."""
|
|
320
|
+
c = _client()
|
|
321
|
+
try:
|
|
322
|
+
card = c.balance()
|
|
323
|
+
credits = c.billing.credits()
|
|
324
|
+
except Exception as e:
|
|
325
|
+
_print_err(e); raise typer.Exit(1)
|
|
326
|
+
if _emit({**credits, "payment_method": card}, as_json):
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
table = Table(show_header=False, box=None)
|
|
330
|
+
table.add_row("Payment method", _describe_card(card))
|
|
331
|
+
table.add_row("Credits", _fmt_usd(credits.get("balance_cents", 0)))
|
|
332
|
+
held = credits.get("held_cents")
|
|
333
|
+
if held:
|
|
334
|
+
table.add_row("Held for running jobs", _fmt_usd(held))
|
|
335
|
+
if credits.get("auto_reload_enabled"):
|
|
336
|
+
table.add_row("Auto-reload", f"on — {_fmt_usd(credits.get('auto_reload_amount_cents', 0))}")
|
|
337
|
+
console.print(table)
|
|
338
|
+
|
|
339
|
+
if card.get("billing_exempt"):
|
|
340
|
+
console.print("\n[dim]This account is billing-exempt — jobs run without a card.[/dim]")
|
|
341
|
+
elif not card.get("has_card"):
|
|
342
|
+
console.print("\nNo card yet. Run [bold]commoncompute billing add-card[/bold].")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _describe_card(card: dict) -> str:
|
|
346
|
+
if card.get("last4"):
|
|
347
|
+
brand = (card.get("brand") or "card").capitalize()
|
|
348
|
+
return f"{brand} ••••{card['last4']}"
|
|
349
|
+
if card.get("has_card"):
|
|
350
|
+
# Stripe was unreachable when the summary was built; the submit gate
|
|
351
|
+
# still works, so report the card rather than implying there isn't one.
|
|
352
|
+
return "on file (details unavailable)"
|
|
353
|
+
return "none"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@billing_app.command("add-card")
|
|
357
|
+
def billing_add_card(
|
|
358
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening it."),
|
|
359
|
+
timeout: float = typer.Option(600.0, "--timeout", help="Seconds to wait for the card to appear."),
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Add a payment card (opens the browser; the card is entered on Stripe's form)."""
|
|
362
|
+
c = _client()
|
|
363
|
+
try:
|
|
364
|
+
card = c.balance()
|
|
365
|
+
except Exception as e:
|
|
366
|
+
_print_err(e); raise typer.Exit(1)
|
|
367
|
+
|
|
368
|
+
if card.get("has_card"):
|
|
369
|
+
console.print(f"[green]✓[/green] Card already on file — {_describe_card(card)}")
|
|
370
|
+
console.print("[dim]Replace it from the dashboard billing page.[/dim]")
|
|
371
|
+
return
|
|
372
|
+
if card.get("billing_exempt"):
|
|
373
|
+
console.print("This account is billing-exempt — no card needed to submit jobs.")
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
url = _dashboard_url(c, "/app/billing")
|
|
377
|
+
console.print("Add your card in the browser — it goes straight to Stripe, not through this CLI.")
|
|
378
|
+
console.print(f" Open: [bold]{url}[/bold]\n")
|
|
379
|
+
if not no_browser:
|
|
380
|
+
try:
|
|
381
|
+
webbrowser.open(url)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass # headless box; the printed URL is the fallback
|
|
384
|
+
|
|
385
|
+
# Poll the read-only summary. The webhook (setup_intent.succeeded) is what
|
|
386
|
+
# actually saves the card, so success here means the card really landed
|
|
387
|
+
# server-side — not merely that the browser form looked happy.
|
|
388
|
+
deadline = time.time() + timeout
|
|
389
|
+
try:
|
|
390
|
+
with console.status("Waiting for the card…"):
|
|
391
|
+
while time.time() < deadline:
|
|
392
|
+
time.sleep(3)
|
|
393
|
+
try:
|
|
394
|
+
card = c.balance()
|
|
395
|
+
except Exception:
|
|
396
|
+
continue # transient; keep waiting until the deadline
|
|
397
|
+
if card.get("has_card"):
|
|
398
|
+
console.print(f"[green]✓[/green] Card saved — {_describe_card(card)}")
|
|
399
|
+
console.print("Next: [bold]commoncompute billing status[/bold]")
|
|
400
|
+
return
|
|
401
|
+
except KeyboardInterrupt:
|
|
402
|
+
console.print("\nStopped waiting. The card is saved if you finished in the browser;")
|
|
403
|
+
console.print("check with [bold]commoncompute billing status[/bold].")
|
|
404
|
+
raise typer.Exit(1)
|
|
405
|
+
|
|
406
|
+
err_console.print(
|
|
407
|
+
f"Timed out after {timeout:.0f}s without seeing a card.\n"
|
|
408
|
+
"If you did add one, check `commoncompute billing status` — the webhook may still be in flight."
|
|
409
|
+
)
|
|
410
|
+
raise typer.Exit(1)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@credits_app.command("buy")
|
|
414
|
+
def credits_buy(
|
|
415
|
+
amount: float = typer.Argument(..., help="Dollars of credit to buy, e.g. 20."),
|
|
416
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
417
|
+
as_json: bool = typer.Option(False, "--json", help="Raw JSON output."),
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Charge your saved card for credits.
|
|
420
|
+
|
|
421
|
+
Needs a key with the `billing` scope — tick "Also let it buy credits" when
|
|
422
|
+
you run `commoncompute login`. Without it you'll get a clear 403.
|
|
423
|
+
"""
|
|
424
|
+
c = _client()
|
|
425
|
+
amount_cents = int(round(amount * 100))
|
|
426
|
+
|
|
427
|
+
# Confirm before charging. This is the one CLI command that moves money, so
|
|
428
|
+
# a mistyped argument is a real charge rather than a failed request.
|
|
429
|
+
#
|
|
430
|
+
# When there's no tty we REFUSE rather than assuming consent. Skipping the
|
|
431
|
+
# prompt because nobody can answer it turns "couldn't ask" into "went
|
|
432
|
+
# ahead" — a cron job or a stray script would charge the card silently. A
|
|
433
|
+
# caller that means it passes --yes.
|
|
434
|
+
if not yes:
|
|
435
|
+
if _stdin_is_tty():
|
|
436
|
+
typer.confirm(f"Charge your saved card {_fmt_usd(amount_cents)}?", abort=True)
|
|
437
|
+
else:
|
|
438
|
+
err_console.print(
|
|
439
|
+
f"Refusing to charge {_fmt_usd(amount_cents)} without confirmation.\n"
|
|
440
|
+
"This isn't an interactive shell, so there's nobody to ask — "
|
|
441
|
+
"pass [bold]--yes[/bold] if you meant it."
|
|
442
|
+
)
|
|
443
|
+
raise typer.Exit(2)
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
res = c.billing.buy_credits(amount_cents=amount_cents)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
_print_err(e)
|
|
449
|
+
# The API distinguishes these; each needs a different action from the
|
|
450
|
+
# user, so don't flatten them into "purchase failed".
|
|
451
|
+
text = str(e)
|
|
452
|
+
if "insufficient_scope" in text or "billing\" scope" in text:
|
|
453
|
+
err_console.print(
|
|
454
|
+
"\nThis key can't buy credits. Re-run [bold]commoncompute login[/bold] and tick\n"
|
|
455
|
+
'"Also let it buy credits" on the approval page, or buy from the dashboard.'
|
|
456
|
+
)
|
|
457
|
+
elif "no_payment_method" in text:
|
|
458
|
+
err_console.print("\nNo card on file. Run [bold]commoncompute billing add-card[/bold] first.")
|
|
459
|
+
raise typer.Exit(1)
|
|
460
|
+
|
|
461
|
+
if _emit(res, as_json):
|
|
462
|
+
return
|
|
463
|
+
console.print(
|
|
464
|
+
f"[green]✓[/green] Added {_fmt_usd(res.get('purchased_cents', amount_cents))} — "
|
|
465
|
+
f"balance {_fmt_usd(res.get('available_cents', 0))}"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@credits_app.command("status")
|
|
470
|
+
def credits_status(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.")) -> None:
|
|
471
|
+
"""Show the credit balance (alias of `billing status`)."""
|
|
472
|
+
billing_status(as_json=as_json)
|
|
473
|
+
|
|
474
|
+
|
|
270
475
|
@app.command()
|
|
271
476
|
def usage(
|
|
272
477
|
start: Optional[str] = typer.Option(None, "--start", help="Window start, YYYY-MM-DD (default: 30 days ago)."),
|
|
@@ -298,6 +298,32 @@ class _BillingSync:
|
|
|
298
298
|
def balance(self) -> dict[str, Any]:
|
|
299
299
|
return self._t.request("GET", "/v1/billing/balance")
|
|
300
300
|
|
|
301
|
+
def credits(self) -> dict[str, Any]:
|
|
302
|
+
"""Prepaid credit balance, what's held against in-flight jobs, and
|
|
303
|
+
auto-reload settings — ``GET /v1/billing/credits``.
|
|
304
|
+
|
|
305
|
+
Read-only, so an API key is enough. Buying credits is a separate,
|
|
306
|
+
JWT-only operation done from the dashboard.
|
|
307
|
+
"""
|
|
308
|
+
return self._t.request("GET", "/v1/billing/credits")
|
|
309
|
+
|
|
310
|
+
def buy_credits(self, *, amount_cents: int, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
311
|
+
"""Charge the saved card for credits — ``POST /v1/billing/credits``.
|
|
312
|
+
|
|
313
|
+
Needs an API key carrying the ``billing`` scope, which is granted only
|
|
314
|
+
by ticking "Also let it buy credits" during ``commoncompute login``.
|
|
315
|
+
A stock key raises with ``insufficient_scope``.
|
|
316
|
+
|
|
317
|
+
Pass ``idempotency_key`` to make a retry safe: the same key never
|
|
318
|
+
charges twice.
|
|
319
|
+
"""
|
|
320
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
321
|
+
return self._t.request(
|
|
322
|
+
"POST", "/v1/billing/credits",
|
|
323
|
+
json_body={"amount_cents": amount_cents},
|
|
324
|
+
headers=headers,
|
|
325
|
+
)
|
|
326
|
+
|
|
301
327
|
def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
302
328
|
return self._t.request("GET", "/v1/billing/history", params={"limit": limit}).get("entries", [])
|
|
303
329
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|