Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies = [
"jsonschema>=4.17.3,<5",
]
name = "beta9"
version = "0.1.279"
version = "0.1.280"
description = ""

[project.scripts]
Expand Down
6 changes: 3 additions & 3 deletions sdk/src/beta9/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def save_login(context: ConfigContext, name: str = DEFAULT_CONTEXT_NAME) -> None
def _post(auth_url: Optional[str], path: str, payload: Dict[str, str]) -> requests.Response:
if not auth_url:
raise LoginError(
"This install has no browser sign-in; create a token in the dashboard and run `config create`.",
"This install has no browser sign-in. Create a token in the dashboard and run `config create`.",
"LOGIN_NOT_CONFIGURED",
)
try:
Expand Down Expand Up @@ -168,7 +168,7 @@ def poll(self) -> Optional[ConfigContext]:
self.interval += 2
return None
if error == "expired_token":
raise LoginError("The sign-in code expired; run login again.", "LOGIN_EXPIRED")
raise LoginError("The sign-in code expired. Run login again.", "LOGIN_EXPIRED")
if error == "access_denied":
raise LoginError("Sign-in was denied in the browser.", "LOGIN_DENIED")
raise LoginError(_message(response, "Sign-in failed"))
Expand All @@ -188,5 +188,5 @@ def wait(self, timeout: Optional[float] = None) -> ConfigContext:
if context is not None:
return context
if time.monotonic() + self.interval > deadline:
raise LoginError("Timed out waiting for sign-in; run login again.", "LOGIN_EXPIRED")
raise LoginError("Timed out waiting for sign-in. Run login again.", "LOGIN_EXPIRED")
time.sleep(self.interval)
76 changes: 56 additions & 20 deletions sdk/src/beta9/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
)

UNIVERSAL_SKILLS_DIR = "~/.agents/skills"
# The minimum credit purchase, which also covers a first VM.
CHECKOUT_CENTS = 2000


@click.group(cls=ClickCommonGroup)
Expand Down Expand Up @@ -57,7 +59,7 @@ def setup():
"--yes",
is_flag=True,
expose_value=False,
help="Accepted for symmetry with installers; nothing prompts.",
help="Accepted for symmetry with installers. Nothing prompts.",
)
@extraclick.config_context_option
def agent(clients: Sequence[str], skip_skills: bool, skip_mcp: bool, context: Optional[str]):
Expand Down Expand Up @@ -139,7 +141,7 @@ def configure_agents(
name="vm",
help="Sign in, connect your agents and start a persistent CPU VM. Run again to resume.",
)
@click.option("--name", help="Name of the first VM; defaults to <product>-agent.")
@click.option("--name", help="Name of the first VM. Defaults to <product>-agent.")
@click.option("--desktop", is_flag=True, help="Include a browser desktop.")
@click.option("--client", "clients", type=click.Choice(CLIENT_IDS), multiple=True)
@click.option("--skip-agents", is_flag=True, help="Sign in and prepare the VM only.")
Expand All @@ -158,12 +160,12 @@ def setup_vm(name, desktop, clients, skip_agents, yes, context):
with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext():
selected_context, agents = connect_onboarding(selected, clients, skip_agents, yes)
if not confirm_workload(
"Start your VM (4 vCPU, 8 GiB RAM, 50 GiB disk; runs until stopped)?", yes
"Start your VM (4 vCPU, 8 GiB RAM, 50 GiB disk, runs until stopped)?", yes
):
return
with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext():
with ServiceClient(get_config_context(selected)) as service:
with terminal.StepTracker().step("Starting VM and checking the guest", "VM ready"):
with terminal.StepTracker().step("Starting VM", "VM ready"):
result = prepare_vm(
service,
get_settings().config_path.parent / "onboarding",
Expand Down Expand Up @@ -222,42 +224,76 @@ def confirm_workload(prompt: str, yes: bool) -> bool:


def ensure_credit(context: ConfigContext, unattended: bool) -> None:
"""Use the gateway's credit decision; purchases stay in the product dashboard."""
import re
"""Use the gateway's credit decision; Enter opens checkout for the signed-in account."""
import time
import webbrowser

from ..mcp.tools import call_remote

summary = call_remote(context, "whoami", {})
credit = summary.get("credit")
credit = call_remote(context, "whoami", {}).get("credit")
if not credit or credit.get("ok"):
return
message = credit.get("message") or "Add credits before starting compute."
match = re.search(r"https?://[^\s<>]+", message)
link = match.group(0).rstrip(".,)") if match else ""
if unattended or not sys.stdin.isatty() or not link:
if unattended or not sys.stdin.isatty():
raise click.ClickException(message)
terminal.header(f"Add credits to {summary.get('workspace_name') or 'your workspace'}")
terminal.print(message)
terminal.header("Add credits")
terminal.print(credit_reason(message), markup=False)
click.prompt(
"Press enter to add credits via Stripe (Ctrl+C to exit)",
f"Press enter to add ${CHECKOUT_CENTS // 100} in credits (Ctrl+C to exit)",
default="",
show_default=False,
prompt_suffix=" ",
)
link = credit_checkout(context) or credits_page(message)
if not link:
raise click.ClickException(message)
webbrowser.open(link, new=2)
terminal.print("Choose an amount and complete checkout. Setup continues when credits arrive.")
terminal.detail(f"If it didn't open: {link}")
deadline = time.monotonic() + 900
with terminal.progress("Waiting for credits"):
with terminal.progress("Waiting for checkout"):
while time.monotonic() < deadline:
if call_remote(context, "whoami", {}).get("credit", {}).get("ok"):
terminal.success("Credits confirmed")
terminal.success("Added credits")
return
time.sleep(3)
raise click.ClickException("Credits are still pending. Run this command again after checkout.")


def credit_reason(message: str) -> str:
"""The first sentence of the gateway's credit message, without the dashboard link."""
import re

reason = re.split(r"[;.]\s", message, maxsplit=1)[0].rstrip(".")
return reason[:1].upper() + reason[1:] + "."


def credits_page(message: str) -> str:
import re

match = re.search(r"https?://[^\s<>]+", message)
return match.group(0).rstrip(".,)") if match else ""


def credit_checkout(context: ConfigContext) -> str:
"""A Stripe Checkout URL for this context's account, or "" if its auth server has none."""
import requests

if not context.auth_url or not context.token:
return ""
try:
response = requests.post(
context.auth_url.rstrip("/") + "/cli/checkout",
json={"total_cents_amount": CHECKOUT_CENTS},
headers={"Authorization": f"Bearer {context.token}"},
timeout=10,
allow_redirects=False,
)
url = response.json().get("url") if response.status_code == 201 else ""
except (requests.RequestException, ValueError, AttributeError):
return ""
return url if isinstance(url, str) and url.startswith("https://") else ""


def connect_onboarding(
selected: str, clients: Sequence[str], skip_agents: bool, unattended: bool
) -> Tuple[ConfigContext, Optional[Dict[str, Any]]]:
Expand All @@ -280,7 +316,7 @@ def connect_onboarding(
agents = None if skip_agents else configure_agents(clients, False, False, selected)
if agents and agents["failed"]:
report_failures(agents["failed"])
raise click.ClickException("Agent configuration failed; fix it and resume setup")
raise click.ClickException("Agent configuration failed. Fix it and resume setup.")
if agents:
terminal.success("Agent skills and MCP configured")
return context, agents
Expand Down Expand Up @@ -380,9 +416,9 @@ def setup_example(kind, name, clients, skip_agents, yes, context):
with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext():
configured, agents = connect_onboarding(selected, clients, skip_agents, yes)
if not confirm_workload(
"Run hello world in a sandbox (1 CPU, 256 MiB; stops when finished)?"
"Run hello world in a sandbox (1 CPU, 256 MiB, stops when finished)?"
if kind == "sandboxes"
else "Run hello world (1 CPU, 256 MiB; scales to zero when idle)?",
else "Run hello world (1 CPU, 256 MiB, scales to zero when idle)?",
yes,
):
return
Expand Down
16 changes: 8 additions & 8 deletions sdk/src/beta9/deployment/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ def prepare_vm(
vm = _create_vm(service, path, state, name, desktop)
else:
if (vm.info.get("metadata") or {}).get("beam.onboarding") != marker["beam.onboarding"]:
raise ValueError(f"VM {name!r} already exists; choose a different --name")
raise ValueError(f"VM {name!r} already exists. Choose a different --name.")
if vm.info.get("desired_state") != "running":
vm.start(wait=False)

vm.wait(timeout=240, services=True)
probe = vm.process.exec("sh", "-c", "test -d /workspace && uname -s", cwd="/")
if probe.wait(30) != 0 or probe.stdout.read().strip() != "Linux":
raise RuntimeError(
"VM guest readiness check failed; inspect vm get/logs and retry setup"
"VM guest readiness check failed. Inspect vm get/logs and retry setup."
)
return vm.refresh().info

Expand Down Expand Up @@ -147,7 +147,7 @@ def prepare_example(
path = directory / "app.py"
if kind == "sandboxes":
if matches and state.get("app_id") != matches[0].get("app_id"):
raise ValueError(f"App {name!r} already exists; choose a different --name")
raise ValueError(f"App {name!r} already exists. Choose a different --name.")
path.write_text(
"from beta9 import Sandbox\n\n"
f"code = '''{SANDBOX_CODE}'''\n\n"
Expand All @@ -172,7 +172,7 @@ def prepare_example(
if matches:
existing = call("get_app", {"name": name})
if marker not in existing.get("config", {}).get("env", []):
raise ValueError(f"App {name!r} already exists; choose a different --name")
raise ValueError(f"App {name!r} already exists. Choose a different --name.")

decorator = "endpoint" if kind == "inference" else "function"
scaling = " keep_warm_seconds=0,\n" if kind == "inference" else ""
Expand Down Expand Up @@ -217,7 +217,7 @@ def prepare_example(
)
if not accepted:
raise RuntimeError(
"Sample deployment was not accepted; inspect logs and resume setup"
"Sample deployment was not accepted. Inspect logs and resume setup."
)
existing = call("get_app", {"name": name})

Expand Down Expand Up @@ -297,7 +297,7 @@ def _run_sandbox_example(

apps = call("list_apps", {"name": name}).get("items", [])
if len(apps) != 1 or not apps[0].get("app_id"):
raise RuntimeError("Sandbox app could not be found; inspect logs before retrying")
raise RuntimeError("Sandbox app could not be found. Inspect logs before retrying.")
state["app_id"] = apps[0]["app_id"]
_save_state(directory / "request.json", state)

Expand All @@ -322,7 +322,7 @@ def _run_sandbox_example(
raise TimeoutError("Sandbox sample did not finish within 30 seconds")
time.sleep(0.1)
if exit_code != 0 or not "".join(output).strip().endswith("Hello from the cloud"):
raise RuntimeError("Sandbox hello-world result was not verified; inspect logs")
raise RuntimeError("Sandbox hello-world result was not verified. Inspect logs.")
return {
"name": name,
"example": "sandboxes",
Expand All @@ -333,7 +333,7 @@ def _run_sandbox_example(
finally:
try:
if instance is not None and instance.ok and not instance.terminate():
raise RuntimeError("Sandbox did not stop; inspect container status")
raise RuntimeError("Sandbox did not stop. Inspect container status.")
finally:
os.chdir(previous_directory)
if previous_channel is None:
Expand Down
61 changes: 55 additions & 6 deletions sdk/tests/test_vm_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,23 +251,34 @@ def test_credit_checkpoint_never_opens_checkout_for_unattended_setup(monkeypatch
opening.assert_not_called()


def test_interactive_credit_checkpoint_waits_for_confirmed_funding(monkeypatch):
def unfunded_setup(monkeypatch, checkout_status=201):
"""An interactive credit checkpoint whose account is funded on the second poll."""
import webbrowser

import requests

from beta9.cli import setup

opening = MagicMock()
prompt = MagicMock(return_value="")
checkout = MagicMock(
return_value=MagicMock(
status_code=checkout_status,
json=lambda: {"url": "https://checkout.stripe.com/c/pay/cs_test"},
)
)
monkeypatch.setattr(webbrowser, "open", opening)
monkeypatch.setattr(setup.click, "prompt", prompt)
monkeypatch.setattr(requests, "post", checkout)
monkeypatch.setattr(setup.sys.stdin, "isatty", lambda: True)
statuses = iter(
[
{
"workspace_name": "test",
"workspace_name": "014913",
"credit": {
"ok": False,
"message": "Add credits at https://account.example/credits",
"message": "This workspace has no credits yet. Add credits at "
"https://account.example/settings/credits to run workloads.",
},
},
{"credit": {"ok": False}},
Expand All @@ -276,14 +287,52 @@ def test_interactive_credit_checkpoint_waits_for_confirmed_funding(monkeypatch):
)
monkeypatch.setattr("beta9.mcp.tools.call_remote", lambda *_: next(statuses))
monkeypatch.setattr("time.sleep", lambda _: None)
setup.ensure_credit(object(), unattended=False)
opening.assert_called_once_with("https://account.example/credits", new=2)
context = ConfigContext(token="workspace-token", auth_url="https://api.example/v2/oauth")
return setup, context, opening, prompt, checkout


def test_enter_opens_a_twenty_dollar_checkout_for_the_signed_in_account(monkeypatch, capsys):
setup, context, opening, prompt, checkout = unfunded_setup(monkeypatch)

setup.ensure_credit(context, unattended=False)

prompt.assert_called_once_with(
"Press enter to add credits via Stripe (Ctrl+C to exit)",
"Press enter to add $20 in credits (Ctrl+C to exit)",
default="",
show_default=False,
prompt_suffix=" ",
)
checkout.assert_called_once()
assert checkout.call_args.args == ("https://api.example/v2/oauth/cli/checkout",)
assert checkout.call_args.kwargs["json"] == {"total_cents_amount": 2000}
assert checkout.call_args.kwargs["headers"] == {"Authorization": "Bearer workspace-token"}
opening.assert_called_once_with("https://checkout.stripe.com/c/pay/cs_test", new=2)
output = capsys.readouterr().out
assert "This workspace has no credits yet." in output
assert "Added credits" in output
assert "014913" not in output
assert ";" not in output


def test_checkout_falls_back_to_the_credits_page(monkeypatch):
setup, context, opening, _, _ = unfunded_setup(monkeypatch, checkout_status=404)

setup.ensure_credit(context, unattended=False)

opening.assert_called_once_with("https://account.example/settings/credits", new=2)


@pytest.mark.parametrize(
"message",
[
"this workspace has no credits yet; add credits at https://x/settings/credits to run workloads",
"This workspace has no credits yet. Add credits at https://x/settings/credits to run workloads.",
],
)
def test_credit_reason_is_one_sentence_without_the_link(message):
from beta9.cli import setup

assert setup.credit_reason(message) == "This workspace has no credits yet."


def test_product_dashboard_templates_are_optional_and_context_bound(monkeypatch):
Expand Down
2 changes: 1 addition & 1 deletion sdk/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading