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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,33 @@ runcrate envs switch staging && runcrate secrets list
A value passed for one launch (`runcrate run --env HF_TOKEN=...`) wins over the
secret of the same name.

## Billing

An agent or a script can see how long the balance lasts and top it up from the
workspace's default card. Charging needs the billing permission on your API key
or sign-in, turned on by a workspace owner or manager on the dashboard API keys
page (with daily and monthly caps). Cards are added in the dashboard
(https://runcrate.ai/dashboard/billing), never from the CLI.

```bash
runcrate billing balance [--json] # credits, burn per hour, runway
runcrate billing usage [--period 7d] # inference usage
runcrate billing topup 50 # asks first; --yes to skip, --json for scripts
runcrate billing topup 50 --idempotency-key <key> --yes # check an earlier top-up, never charges twice
runcrate billing auto-recharge # on or off, threshold, target, last failure
runcrate billing auto-recharge set --threshold 20 --target 100
runcrate billing auto-recharge off # keeps the amounts
runcrate billing payment-methods # brand, last 4 digits, expiry, default
```

- Every top-up has an idempotency key, a new one unless you pass
`--idempotency-key`, and the CLI prints it. Sending the same key again
answers that top-up's current status instead of charging again.
- `requires_action` means the bank wants the payment confirmed: a workspace
owner or manager opens the printed link, signed in. Nothing is charged until
then. `processing` settles on its own; check it with the same key.
- A declined top-up prints why and exits non-zero.

## AI clients (MCP)

Runcrate runs a remote MCP server at `https://mcp.runcrate.ai/mcp`. The quickest
Expand Down
117 changes: 114 additions & 3 deletions internal/api/billing.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,71 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
)

// Balance mirrors /api/v1/billing/balance (getProjectBalance), which returns
// camelCase creditsBalance + activeUsageCost.
// camelCase creditsBalance + activeUsageCost. The snake_case fields are nil on
// servers that do not send them; RunwayHours is also nil when nothing is
// spending.
type Balance struct {
Credits float64 `json:"creditsBalance"`
ActiveUsageCost float64 `json:"activeUsageCost"`
Credits float64 `json:"creditsBalance"`
ActiveUsageCost float64 `json:"activeUsageCost"`
FloatingBalanceUsd *float64 `json:"floating_balance_usd,omitempty"`
BurnUsdPerHour *float64 `json:"burn_usd_per_hour,omitempty"`
RunwayHours *float64 `json:"runway_hours,omitempty"`
}

// Top-up statuses. Every one answers HTTP 200.
const (
TopUpSucceeded = "succeeded"
TopUpProcessing = "processing"
TopUpRequiresAction = "requires_action"
TopUpDeclined = "declined"
)

// TopUp mirrors POST /api/v1/billing/topups. BalanceUsd is set when it
// succeeded; ConfirmURL and ExpiresAt when it needs a workspace owner or
// manager to confirm it; Reason and Message when it was declined.
type TopUp struct {
ID string `json:"id"`
Status string `json:"status"`
AmountUsd float64 `json:"amount_usd"`
IdempotencyKey string `json:"idempotency_key"`
InvoiceNumber *string `json:"invoice_number"`
BalanceUsd *float64 `json:"balance_usd,omitempty"`
ConfirmURL string `json:"confirm_url,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
CreatedAt string `json:"created_at"`
// Replayed is true when the key had been sent before: the answer is that
// top-up's current status, not a new charge.
Replayed bool `json:"-"`
}

// AutoRecharge mirrors /api/v1/billing/auto-recharge: below ThresholdUsd the
// balance is topped up to TargetUsd from the default card.
type AutoRecharge struct {
Enabled bool `json:"enabled"`
ThresholdUsd *float64 `json:"threshold_usd"`
TargetUsd *float64 `json:"target_usd"`
LastFailureReason *string `json:"last_failure_reason"`
LastFailureAt *string `json:"last_failure_at"`
}

// PaymentMethod mirrors one entry of /api/v1/billing/payment-methods.
type PaymentMethod struct {
Type string `json:"type"`
Brand *string `json:"brand"`
Last4 *string `json:"last4"`
ExpMonth *int `json:"exp_month"`
ExpYear *int `json:"exp_year"`
Default bool `json:"default"`
}

// UsageSummary mirrors the get_usage_summary RPC behind /api/v1/billing/usage.
Expand Down Expand Up @@ -64,3 +119,59 @@ func (c *Client) GetUsage(from string) (*UsageSummary, error) {
}
return &result.Data, nil
}

// TopUp charges amount (USD) to the workspace's default card and adds it to
// the balance. An empty idempotencyKey gets a fresh one, returned on the
// result. Sending the same key again answers that top-up's current status
// instead of charging again, which is how a processing or requires_action
// top-up is checked. When no answer came, the error is a *SubmitNetworkError
// carrying the key to send again.
func (c *Client) TopUp(amount float64, idempotencyKey string) (*TopUp, error) {
if idempotencyKey == "" {
idempotencyKey = NewIdempotencyKey()
}
raw, err := json.Marshal(map[string]any{"amount_usd": amount, "idempotency_key": idempotencyKey})
if err != nil {
return nil, fmt.Errorf("encoding request: %w", err)
}
resp, err := c.doCtxHeaders(context.Background(), "POST", "/api/v1/billing/topups", bytes.NewReader(raw), nil)
if err != nil {
return nil, &SubmitNetworkError{Key: idempotencyKey, Err: err}
}
defer resp.Body.Close()
if err := checkHTTPStatus(resp); err != nil {
return nil, err
}

var result apiEnvelope[TopUp]
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
t := &result.Data
if t.IdempotencyKey == "" {
t.IdempotencyKey = idempotencyKey
}
t.Replayed = resp.Header.Get("Idempotent-Replayed") == "true"
return t, nil
}

// GetAutoRecharge returns the workspace's auto-recharge setting.
func (c *Client) GetAutoRecharge() (*AutoRecharge, error) {
return callJSON[AutoRecharge](c, "GET", "/api/v1/billing/auto-recharge", nil)
}

// SetAutoRecharge turns auto-recharge on or off. The server needs threshold
// and target even to turn it off.
func (c *Client) SetAutoRecharge(enabled bool, threshold, target float64) (*AutoRecharge, error) {
body := map[string]any{"enabled": enabled, "threshold_usd": threshold, "target_usd": target}
return callJSON[AutoRecharge](c, "PUT", "/api/v1/billing/auto-recharge", body)
}

// ListPaymentMethods returns the workspace's cards.
func (c *Client) ListPaymentMethods() ([]PaymentMethod, error) {
list, err := callJSON[[]PaymentMethod](c, "GET", "/api/v1/billing/payment-methods", nil)
if err != nil {
return nil, err
}
return *list, nil
}
6 changes: 3 additions & 3 deletions internal/api/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ const (
GroupCancelTimeout = 315 * time.Second
)

// SubmitNetworkError is a submit whose answer never came: the job may have
// been created. Sending the same request with Key within 24 hours answers that
// job instead of starting another.
// SubmitNetworkError is a submit (a job or a top-up) whose answer never came:
// it may have been made. Sending the same request with Key answers that one
// instead of making another.
type SubmitNetworkError struct {
Key string
Err error
Expand Down
Loading
Loading