Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/runware.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ Use of Runware services is subject to our Terms of Service (https://runware.ai/t
* [runware preset](runware_preset.md) - Manage named presets
* [runware result](runware_result.md) - Wait for and display the result of a task by taskUUID
* [runware run](runware_run.md) - Run an inference request against any Runware model
* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments
* [runware version](runware_version.md) - Print version information

30 changes: 30 additions & 0 deletions docs/runware_serverless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## runware serverless

Manage Runware serverless deployments

### Synopsis

Deploy, monitor, and manage Runware serverless applications on the platform

### Options

```
-h, --help help for serverless
```

### Options inherited from parent commands

```
--debug Show full debug output
-F, --format string CLI output format: table, json, yaml (default "table")
--transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws")
-v, --verbose Show request/response details
```

### SEE ALSO

* [runware](runware.md) - CLI tool for the Runware API
* [runware serverless deploy](runware_serverless_deploy.md) - Deploy a new serverless application
* [runware serverless gpus](runware_serverless_gpus.md) - List available GPU types and pricing
* [runware serverless open](runware_serverless_open.md) - Open a deployment in the Runware dashboard

44 changes: 44 additions & 0 deletions docs/runware_serverless_deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## runware serverless deploy

Deploy a new serverless application

### Synopsis

Deploy a new serverless application from a Python file.

The application settings come from the project
configuration created by 'runware serverless init' or the dashboard.

```
runware serverless deploy [file] [flags]
```

### Examples

```
# deploy the application in the current project
runware serverless deploy

# deploy a specific Python file
runware serverless deploy ./app.py
```

### Options

```
-h, --help help for deploy
```

### Options inherited from parent commands

```
--debug Show full debug output
-F, --format string CLI output format: table, json, yaml (default "table")
--transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws")
-v, --verbose Show request/response details
```

### SEE ALSO

* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments

34 changes: 34 additions & 0 deletions docs/runware_serverless_gpus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## runware serverless gpus

List available GPU types and pricing

```
runware serverless gpus [flags]
```

### Examples

```
# list GPU types and per-second pricing
runware serverless gpus
```

### Options

```
-h, --help help for gpus
```

### Options inherited from parent commands

```
--debug Show full debug output
-F, --format string CLI output format: table, json, yaml (default "table")
--transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws")
-v, --verbose Show request/response details
```

### SEE ALSO

* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments

34 changes: 34 additions & 0 deletions docs/runware_serverless_open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## runware serverless open

Open a deployment in the Runware dashboard

```
runware serverless open <deploymentId> [flags]
```

### Examples

```
# open a deployment's dashboard page in your browser
runware serverless open my-model-abc
```

### Options

```
-h, --help help for open
```

### Options inherited from parent commands

```
--debug Show full debug output
-F, --format string CLI output format: table, json, yaml (default "table")
--transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws")
-v, --verbose Show request/response details
```

### SEE ALSO

* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments

92 changes: 92 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Package serverless is a REST client for the Runware Serverless control-plane
// API (api.serverless.runware.ai). It is separate from the inference task
// transport in internal/api/transport, which speaks the task-array protocol.
package serverless

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"

"github.com/runware/runware-cli/internal/agents"
"github.com/runware/runware-cli/internal/api/transport"
"github.com/runware/runware-cli/internal/buildinfo"
)

// defaultTimeout bounds a single REST request.
const defaultTimeout = 30 * time.Second

// Client talks to the Serverless control-plane REST API.
type Client struct {
apiKey string
baseURL string
userAgent string
logger *slog.Logger
httpClient *http.Client
}

// NewClient creates a Serverless API client for the given API key and base URL.
func NewClient(apiKey, baseURL string, logger *slog.Logger) *Client {
ua := buildinfo.UserAgent()
if agent := agents.Detect(); agent != "" {
ua += " agent/" + string(agent)
}
return &Client{
apiKey: apiKey,
baseURL: strings.TrimSuffix(baseURL, "/"),
userAgent: ua,
logger: logger,
httpClient: &http.Client{Timeout: defaultTimeout},
}
}

// get performs an authenticated GET on path (e.g. "/v1/gpu-types") and decodes
// the JSON response body into out.
func (c *Client) get(ctx context.Context, path string, out any) error {
if c.apiKey == "" {
return transport.ErrNoAPIKey
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("User-Agent", c.userAgent)

start := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() //nolint:errcheck,gosec

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}

if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) {
c.logger.Debug("serverless response", //nolint:errcheck,gosec
"url", c.baseURL+path,
"status", resp.StatusCode,
"elapsed", time.Since(start).Round(time.Millisecond),
"body", string(body),
)
}

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return parseError(body, resp.StatusCode)
}

if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("failed to parse response (HTTP %d): %w", resp.StatusCode, err)
}
return nil
}
51 changes: 51 additions & 0 deletions internal/api/serverless/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package serverless

import (
"encoding/json"
"fmt"
"net/http"

"github.com/runware/runware-cli/internal/api/transport"
)

// wireError is the Serverless API error envelope: {"code":int,"message":string}.
type wireError struct {
Code int `json:"code"`
Message string `json:"message"`
}

// parseError converts a non-2xx Serverless API response into a
// *transport.RunwareError so the CLI's shared error rendering (including the
// auth hint on 401/403) applies uniformly.
func parseError(body []byte, statusCode int) error {
var w wireError
if err := json.Unmarshal(body, &w); err != nil || w.Message == "" {
return transport.CreateRunwareError(
rawCodeForStatus(statusCode),
fmt.Sprintf("HTTP %d: %s", statusCode, http.StatusText(statusCode)),
transport.RunwareErrorDetails{StatusCode: statusCode},
)
}
return transport.CreateRunwareError(
rawCodeForStatus(statusCode),
w.Message,
transport.RunwareErrorDetails{StatusCode: statusCode},
)
}

// rawCodeForStatus maps an HTTP status to a raw error code string that
// transport.DeriveCode categorises correctly (notably 401/403 -> auth).
func rawCodeForStatus(statusCode int) string {
switch statusCode {
case http.StatusUnauthorized:
return "unauthorized"
case http.StatusForbidden:
return "forbidden"
case http.StatusNotFound:
return "notFound"
case http.StatusConflict:
return "conflict"
default:
return "serverError"
}
}
18 changes: 18 additions & 0 deletions internal/api/serverless/gpu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package serverless

import "context"

// gpuTypesResponse is the envelope returned by GET /v1/gpu-types.
type gpuTypesResponse struct {
Data []GpuType `json:"data"`
}

// ListGpuTypes returns the catalogue of supported GPU types and their pricing.
// This endpoint is not workspace-scoped.
func (c *Client) ListGpuTypes(ctx context.Context) ([]GpuType, error) {
var resp gpuTypesResponse
if err := c.get(ctx, "/v1/gpu-types", &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
66 changes: 66 additions & 0 deletions internal/api/serverless/gpu_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package serverless

import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"

"github.com/runware/runware-cli/internal/api/transport"
)

func TestListGpuTypes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/gpu-types" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("missing/incorrect auth header: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"h100","name":"H100","memory":"80GB","availability":"available","pricing":{"currency":"USD","perSecond":"0.000767"}}]}`))
}))
defer srv.Close()

c := NewClient("test-key", srv.URL, slog.Default())
gpus, err := c.ListGpuTypes(context.Background())
if err != nil {
t.Fatalf("ListGpuTypes: %v", err)
}
if len(gpus) != 1 {
t.Fatalf("expected 1 gpu, got %d", len(gpus))
}
if gpus[0].ID != "h100" || gpus[0].Pricing.PerSecond != "0.000767" {
t.Errorf("unexpected gpu: %+v", gpus[0])
}
if gpus[0].Memory == nil || *gpus[0].Memory != "80GB" {
t.Errorf("unexpected memory: %v", gpus[0].Memory)
}
}

func TestListGpuTypes_NoAPIKey(t *testing.T) {
c := NewClient("", "https://example.invalid", slog.Default())
if _, err := c.ListGpuTypes(context.Background()); !errors.Is(err, transport.ErrNoAPIKey) {
t.Fatalf("expected ErrNoAPIKey, got %v", err)
}
}

func TestListGpuTypes_AuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"Missing or invalid API key"}`))
}))
defer srv.Close()

c := NewClient("bad", srv.URL, slog.Default())
_, err := c.ListGpuTypes(context.Background())
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.Code != transport.CodeAuth {
t.Errorf("expected CodeAuth, got %v", re.Code)
}
}
Loading