commoncompute 0.3.0__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.0 → commoncompute-0.4.0}/.gitignore +4 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/LICENSE +1 -1
- {commoncompute-0.3.0 → commoncompute-0.4.0}/PKG-INFO +2 -2
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/__init__.py +3 -1
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_async_client.py +18 -1
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_generated_workloads.py +7 -4
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/aws_batch.py +5 -6
- commoncompute-0.4.0/commoncompute/billing.py +124 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/cli.py +243 -7
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/client.py +45 -1
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/tasks.py +27 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/pyproject.toml +1 -1
- {commoncompute-0.3.0 → commoncompute-0.4.0}/README.md +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_async_tasks.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_config.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_transport.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/compat/__init__.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/compat/openai.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/connect.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/errors.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/models.py +0 -0
- {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/py.typed +0 -0
|
@@ -3,6 +3,7 @@ node_modules/
|
|
|
3
3
|
dist/
|
|
4
4
|
coverage/
|
|
5
5
|
.wrangler/
|
|
6
|
+
.wrangler.drifted-backup-*/
|
|
6
7
|
.pytest_cache/
|
|
7
8
|
__pycache__/
|
|
8
9
|
*.pyc
|
|
@@ -36,3 +37,6 @@ apps/web/.playwright/
|
|
|
36
37
|
# Local scratch — never tracked (internal notes, draft reviews)
|
|
37
38
|
tmp/
|
|
38
39
|
.venv
|
|
40
|
+
|
|
41
|
+
# Device reliability reset snapshots (rollback data, never tracked)
|
|
42
|
+
scripts/.reliability-snapshots/
|
|
@@ -1,10 +1,10 @@
|
|
|
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
|
|
7
|
-
Project-URL: Source, https://github.com/
|
|
7
|
+
Project-URL: Source, https://github.com/commoncompute/sdk
|
|
8
8
|
Project-URL: Changelog, https://commoncompute.ai/changelog
|
|
9
9
|
Author-email: Common Compute <support@commoncompute.ai>
|
|
10
10
|
License: MIT
|
|
@@ -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",
|
|
@@ -278,8 +278,10 @@ class _KeysAsync:
|
|
|
278
278
|
async def list(self) -> list[dict[str, Any]]:
|
|
279
279
|
return (await self._t.request("GET", "/v1/me/keys")).get("keys", [])
|
|
280
280
|
|
|
281
|
-
async def create(self, *, name: str, mode: str = "live", scopes: "Optional[Sequence[str]]" = None) -> dict[str, Any]:
|
|
281
|
+
async def create(self, *, name: str, mode: str = "live", scopes: "Optional[Sequence[str]]" = None, password: "Optional[str]" = None) -> dict[str, Any]:
|
|
282
282
|
body: dict[str, Any] = {"name": name, "mode": mode}
|
|
283
|
+
if password:
|
|
284
|
+
body["password"] = password
|
|
283
285
|
if scopes:
|
|
284
286
|
body["scopes"] = scopes
|
|
285
287
|
return await self._t.request("POST", "/v1/me/keys", json_body=body)
|
|
@@ -295,6 +297,21 @@ class _BillingAsync:
|
|
|
295
297
|
async def balance(self) -> dict[str, Any]:
|
|
296
298
|
return await self._t.request("GET", "/v1/billing/balance")
|
|
297
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
|
+
|
|
298
315
|
async def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
299
316
|
return (await self._t.request("GET", "/v1/billing/history", params={"limit": limit})).get("entries", [])
|
|
300
317
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
# @generated
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# @generated from Common Compute's internal workload catalog — DO NOT EDIT.
|
|
2
|
+
#
|
|
3
|
+
# This file is emitted by an internal codegen step and is not regenerable from
|
|
4
|
+
# this repository. Edits here will be overwritten; the catalog is the source of
|
|
5
|
+
# truth and drift is caught before release.
|
|
6
|
+
#
|
|
7
|
+
# Contains workload IDs and image-name routing heuristics only.
|
|
5
8
|
from __future__ import annotations
|
|
6
9
|
|
|
7
10
|
# Every workload id in the catalog. Used to decide whether a jobDefinition head
|
|
@@ -103,11 +103,10 @@ def _to_iso(ms: Optional[int]) -> Optional[str]:
|
|
|
103
103
|
# Heuristic: anything containing one of these workload ids gets routed
|
|
104
104
|
# directly. Otherwise we default to ``coreml_embed`` (the most common
|
|
105
105
|
# batch workload migrated from AWS Batch + bge-* embeddings pipelines).
|
|
106
|
-
# Workload identity comes from the generated catalog mirror
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# npx tsx packages/workloads/codegen/emit-sdk-workloads.ts
|
|
106
|
+
# Workload identity comes from the generated catalog mirror
|
|
107
|
+
# (``_generated_workloads.py``) so this shim resolves jobDefinitions
|
|
108
|
+
# identically to the server side and to the Node SDK, instead of drifting
|
|
109
|
+
# from a hand-maintained list.
|
|
111
110
|
from ._generated_workloads import IMAGE_PATTERNS as _IMAGE_PATTERN_SOURCES
|
|
112
111
|
from ._generated_workloads import KNOWN_WORKLOAD_IDS as _KNOWN_WORKLOADS
|
|
113
112
|
|
|
@@ -403,7 +402,7 @@ class BatchClient:
|
|
|
403
402
|
"""Mirror AWS describe_jobs — returns ``{"jobs": [...]}``.
|
|
404
403
|
|
|
405
404
|
Just-submitted jobs may not be queryable for ~100ms while the
|
|
406
|
-
underlying
|
|
405
|
+
underlying row propagates across the backing datastore's read replicas.
|
|
407
406
|
We absorb a single 404 by retrying once after 250ms, which is
|
|
408
407
|
enough to mask the eventual-consistency window in practice."""
|
|
409
408
|
out: list[dict[str, Any]] = []
|
|
@@ -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)."),
|
|
@@ -435,10 +640,46 @@ def _wait_job(client: Client, job_id: str, timeout: float, *, quiet: bool):
|
|
|
435
640
|
return client.wait(job_id, timeout=timeout, on_progress=_tick)
|
|
436
641
|
|
|
437
642
|
|
|
643
|
+
def _parse_payload_arg(payload: str) -> object:
|
|
644
|
+
"""Resolve --payload: @- (stdin), @path or bare path (file), or inline JSON.
|
|
645
|
+
|
|
646
|
+
The help has always said "path to JSON payload file", but the code only
|
|
647
|
+
honoured that with an @ prefix — a bare path fell through to json.loads()
|
|
648
|
+
and died with a raw JSONDecodeError traceback. Accept the documented
|
|
649
|
+
form, keep both @ forms, and turn every failure into a clean CLI error.
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
if payload == "@-":
|
|
653
|
+
return json.load(sys.stdin)
|
|
654
|
+
if payload.startswith("@"):
|
|
655
|
+
return json.loads(Path(payload[1:]).read_text())
|
|
656
|
+
p = Path(payload)
|
|
657
|
+
if p.exists():
|
|
658
|
+
return json.loads(p.read_text())
|
|
659
|
+
# Not a file on disk — treat as inline JSON. A path-like string that
|
|
660
|
+
# simply doesn't exist gets a hint instead of a JSON parse error.
|
|
661
|
+
try:
|
|
662
|
+
return json.loads(payload)
|
|
663
|
+
except json.JSONDecodeError:
|
|
664
|
+
if payload.endswith(".json") or "/" in payload:
|
|
665
|
+
err_console.print(f"[red]Error:[/red] payload file not found: {payload}")
|
|
666
|
+
else:
|
|
667
|
+
err_console.print(f"[red]Error:[/red] --payload is not a file, not valid inline JSON: {payload!r}")
|
|
668
|
+
raise typer.Exit(2)
|
|
669
|
+
except typer.Exit:
|
|
670
|
+
raise
|
|
671
|
+
except json.JSONDecodeError as e:
|
|
672
|
+
err_console.print(f"[red]Error:[/red] invalid JSON in payload ({e.msg} at line {e.lineno}, column {e.colno})")
|
|
673
|
+
raise typer.Exit(2)
|
|
674
|
+
except OSError as e:
|
|
675
|
+
err_console.print(f"[red]Error:[/red] cannot read payload: {e}")
|
|
676
|
+
raise typer.Exit(2)
|
|
677
|
+
|
|
678
|
+
|
|
438
679
|
@app.command()
|
|
439
680
|
def submit(
|
|
440
681
|
workload: str = typer.Argument(..., help="Workload id."),
|
|
441
|
-
payload: Optional[str] = typer.Option(None, "--payload", "-f", help="Path to JSON payload file,
|
|
682
|
+
payload: Optional[str] = typer.Option(None, "--payload", "-f", help="Path to a JSON payload file, @- for stdin, or an inline JSON string."),
|
|
442
683
|
model: Optional[str] = typer.Option(None, "--model", "-m"),
|
|
443
684
|
priority: str = typer.Option("standard", "--priority", "-p"),
|
|
444
685
|
wait: bool = typer.Option(False, "--wait", help="Block until the job finishes."),
|
|
@@ -448,12 +689,7 @@ def submit(
|
|
|
448
689
|
"""Submit a native Common Compute job."""
|
|
449
690
|
body: object = None
|
|
450
691
|
if payload:
|
|
451
|
-
|
|
452
|
-
body = json.load(sys.stdin)
|
|
453
|
-
elif payload.startswith("@"):
|
|
454
|
-
body = json.loads(Path(payload[1:]).read_text())
|
|
455
|
-
else:
|
|
456
|
-
body = json.loads(payload)
|
|
692
|
+
body = _parse_payload_arg(payload)
|
|
457
693
|
c = _client()
|
|
458
694
|
try:
|
|
459
695
|
job = c.jobs.submit(workload, payload=body, model_id=model, priority=priority)
|
|
@@ -263,10 +263,28 @@ class _KeysSync:
|
|
|
263
263
|
def list(self) -> list[dict[str, Any]]:
|
|
264
264
|
return self._t.request("GET", "/v1/me/keys").get("keys", [])
|
|
265
265
|
|
|
266
|
-
def create(
|
|
266
|
+
def create(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
name: str,
|
|
270
|
+
mode: str = "live",
|
|
271
|
+
scopes: "Optional[Sequence[str]]" = None,
|
|
272
|
+
password: "Optional[str]" = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""Create an API key.
|
|
275
|
+
|
|
276
|
+
Requires a signed-in *session* plus the account password. An API key
|
|
277
|
+
cannot create another API key (the server returns 403
|
|
278
|
+
``api_key_cannot_mint``) — otherwise revoking a leaked key would not
|
|
279
|
+
revoke the keys it had spawned. Since this SDK normally authenticates
|
|
280
|
+
with an API key, most callers should create keys in the dashboard at
|
|
281
|
+
/app/api-keys instead.
|
|
282
|
+
"""
|
|
267
283
|
body: dict[str, Any] = {"name": name, "mode": mode}
|
|
268
284
|
if scopes:
|
|
269
285
|
body["scopes"] = scopes
|
|
286
|
+
if password:
|
|
287
|
+
body["password"] = password
|
|
270
288
|
return self._t.request("POST", "/v1/me/keys", json_body=body)
|
|
271
289
|
|
|
272
290
|
def revoke(self, key_id: str) -> None:
|
|
@@ -280,6 +298,32 @@ class _BillingSync:
|
|
|
280
298
|
def balance(self) -> dict[str, Any]:
|
|
281
299
|
return self._t.request("GET", "/v1/billing/balance")
|
|
282
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
|
+
|
|
283
327
|
def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
284
328
|
return self._t.request("GET", "/v1/billing/history", params={"limit": limit}).get("entries", [])
|
|
285
329
|
|
|
@@ -27,12 +27,31 @@ from .models import Job, Quote, Receipt, TaskResult
|
|
|
27
27
|
|
|
28
28
|
FileInput = Union[str, Path, bytes, io.BufferedReader]
|
|
29
29
|
|
|
30
|
+
# Schemes recognised as "this is a URI, not a local path". Only https:// can
|
|
31
|
+
# actually be fetched: the provider's download gate hard-rejects every other
|
|
32
|
+
# scheme (TrustedDownload.swift — "only https"), and http:// specifically is
|
|
33
|
+
# refused because a job input must not travel in the clear.
|
|
34
|
+
#
|
|
35
|
+
# The others stay in this tuple ON PURPOSE. Dropping them would make an s3://
|
|
36
|
+
# URI fall through to the local-path branch and raise a confusing
|
|
37
|
+
# "No such file: s3://bucket/key"; keeping them lets _file_payload reject the
|
|
38
|
+
# scheme by name, at submit time, instead of the job dispatching and failing
|
|
39
|
+
# on someone's Mac minutes later (2026-08-01 audit — the SDKs documented all
|
|
40
|
+
# five schemes and only one ever worked).
|
|
30
41
|
_URI_SCHEMES = ("http://", "https://", "s3://", "r2://", "gs://")
|
|
42
|
+
_FETCHABLE_URI_SCHEMES = ("https://",)
|
|
31
43
|
|
|
32
44
|
|
|
33
45
|
def _file_payload(f: FileInput, *, allowed_suffixes: Optional[Sequence[str]] = None) -> dict:
|
|
34
46
|
"""Normalise a file input into the job-payload contract."""
|
|
35
47
|
if isinstance(f, str) and f.startswith(_URI_SCHEMES):
|
|
48
|
+
if not f.startswith(_FETCHABLE_URI_SCHEMES):
|
|
49
|
+
scheme = f.split("://", 1)[0]
|
|
50
|
+
raise UnsupportedFormatError(
|
|
51
|
+
f"{scheme}:// inputs are not supported — providers can only fetch https:// URLs. "
|
|
52
|
+
f"Pass a local path (the file is uploaded for you) or a presigned https:// URL.",
|
|
53
|
+
code="unsupported_input_scheme",
|
|
54
|
+
)
|
|
36
55
|
return {"input_uri": f}
|
|
37
56
|
if isinstance(f, (str, Path)):
|
|
38
57
|
p = Path(f)
|
|
@@ -367,6 +386,14 @@ class Video(_TaskBase):
|
|
|
367
386
|
max_spend_usd: Optional[float] = None,
|
|
368
387
|
dry_run: bool = False,
|
|
369
388
|
) -> Union[Job, Quote]:
|
|
389
|
+
"""Transcode a video with VideoToolbox hardware encoders.
|
|
390
|
+
|
|
391
|
+
``output_format`` selects the codec (``"h264"`` / ``"hevc"`` /
|
|
392
|
+
``"prores"``); ``codec=`` overrides it if both are given.
|
|
393
|
+
``resolution`` is not yet implemented server-side — a job that sets
|
|
394
|
+
it fails fast with a clear error instead of silently encoding at
|
|
395
|
+
the source's native size.
|
|
396
|
+
"""
|
|
370
397
|
workload_id, payload, units, model_id = _transcode_request(
|
|
371
398
|
input, output_format, resolution=resolution, codec=codec, output_minutes=output_minutes,
|
|
372
399
|
)
|
|
@@ -38,7 +38,7 @@ dependencies = [
|
|
|
38
38
|
[project.urls]
|
|
39
39
|
Homepage = "https://commoncompute.ai"
|
|
40
40
|
Documentation = "https://commoncompute.ai/docs"
|
|
41
|
-
Source = "https://github.com/
|
|
41
|
+
Source = "https://github.com/commoncompute/sdk"
|
|
42
42
|
Changelog = "https://commoncompute.ai/changelog"
|
|
43
43
|
|
|
44
44
|
[project.scripts]
|
|
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
|