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.
Files changed (22) hide show
  1. {commoncompute-0.3.0 → commoncompute-0.4.0}/.gitignore +4 -0
  2. {commoncompute-0.3.0 → commoncompute-0.4.0}/LICENSE +1 -1
  3. {commoncompute-0.3.0 → commoncompute-0.4.0}/PKG-INFO +2 -2
  4. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/__init__.py +3 -1
  5. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_async_client.py +18 -1
  6. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_generated_workloads.py +7 -4
  7. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/aws_batch.py +5 -6
  8. commoncompute-0.4.0/commoncompute/billing.py +124 -0
  9. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/cli.py +243 -7
  10. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/client.py +45 -1
  11. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/tasks.py +27 -0
  12. {commoncompute-0.3.0 → commoncompute-0.4.0}/pyproject.toml +1 -1
  13. {commoncompute-0.3.0 → commoncompute-0.4.0}/README.md +0 -0
  14. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_async_tasks.py +0 -0
  15. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_config.py +0 -0
  16. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/_transport.py +0 -0
  17. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/compat/__init__.py +0 -0
  18. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/compat/openai.py +0 -0
  19. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/connect.py +0 -0
  20. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/errors.py +0 -0
  21. {commoncompute-0.3.0 → commoncompute-0.4.0}/commoncompute/models.py +0 -0
  22. {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,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Common Compute, Inc.
3
+ Copyright (c) 2026 Common Compute LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commoncompute
3
- Version: 0.3.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/Ikaikaalika/commoncomputeai
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.3.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 by packages/workloads/codegen/emit-sdk-workloads.ts — DO NOT EDIT.
2
- # Regenerate: npx tsx packages/workloads/codegen/emit-sdk-workloads.ts
3
- # Source of truth: packages/workloads (the shared catalog). CI fails if this
4
- # file drifts from the catalog (see tests/unit/workloads-parity.test.ts).
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 so this shim
107
- # resolves jobDefinitions identically to the wire shim (apps/aws-shim) and the
108
- # Node SDK. The previous hand-maintained set had drifted to 12 of the catalog's
109
- # 24 workloads. Regenerate with:
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 D1 row propagates across the worker's read replicas.
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, or @- for stdin."),
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
- if payload == "@-":
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(self, *, name: str, mode: str = "live", scopes: "Optional[Sequence[str]]" = None) -> dict[str, Any]:
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/Ikaikaalika/commoncomputeai"
41
+ Source = "https://github.com/commoncompute/sdk"
42
42
  Changelog = "https://commoncompute.ai/changelog"
43
43
 
44
44
  [project.scripts]
File without changes