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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Improvements

- `exo ai model list` and `exo ai deployment list` don't list in all zones like other commands do
- `exo ai model download` is an alias for `exo ai model create`
- `exo ai model show` and `exo ai model delete` behave like deployments, and be able to search models by name
- `exo ai model show` and `exo ai model list` show model size in MiB/GiB


## 1.92.0

### Features
Expand Down
53 changes: 31 additions & 22 deletions cmd/aiservices/deployment/deployment_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
exocmd "github.com/exoscale/cli/cmd"
"github.com/exoscale/cli/pkg/globalstate"
"github.com/exoscale/cli/pkg/output"
"github.com/exoscale/cli/utils"
v3 "github.com/exoscale/egoscale/v3"
"github.com/spf13/cobra"
)

type DeploymentListItemOutput struct {
ID v3.UUID `json:"id"`
Name string `json:"name"`
Zone v3.ZoneName `json:"zone"`
Status v3.ListDeploymentsResponseEntryStatus `json:"status"`
GPUType string `json:"gpu_type"`
GPUCount int64 `json:"gpu_count"`
Expand Down Expand Up @@ -44,39 +46,46 @@ Supported output template annotations: %s`,
strings.Join(output.TemplateAnnotations(&DeploymentListOutput{}), ", "))
}
func (c *DeploymentListCmd) CmdPreRun(cmd *cobra.Command, args []string) error {
exocmd.CmdSetZoneFlagFromDefault(cmd)
return exocmd.CliCommandDefaultPreRun(c, cmd, args)
}
func (c *DeploymentListCmd) CmdRun(_ *cobra.Command, _ []string) error {
ctx := exocmd.GContext
client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone)
if err != nil {
return err
}
client := globalstate.EgoscaleV3Client

resp, err := client.ListDeployments(ctx)
zones, err := utils.AllZonesV3(ctx, client, c.Zone)
if err != nil {
return err
}

out := make(DeploymentListOutput, 0, len(resp.Deployments))
for _, d := range resp.Deployments {
var modelName string
if d.Model != nil {
modelName = d.Model.Name
out := make(DeploymentListOutput, 0)
err = utils.ForEveryZone(zones, func(zone v3.Zone) error {
c := client.WithEndpoint(zone.APIEndpoint)
resp, err := c.ListDeployments(ctx)
if err != nil {
return err
}
out = append(out, DeploymentListItemOutput{
ID: d.ID,
Name: d.Name,
Status: d.Status,
GPUType: d.GpuType,
GPUCount: d.GpuCount,
Replicas: d.Replicas,
ModelName: modelName,
})
}

return c.OutputFunc(&out, nil)
for _, d := range resp.Deployments {
var modelName string
if d.Model != nil {
modelName = d.Model.Name
}
out = append(out, DeploymentListItemOutput{
ID: d.ID,
Name: d.Name,
Zone: zone.Name,
Status: d.Status,
GPUType: d.GpuType,
GPUCount: d.GpuCount,
Replicas: d.Replicas,
ModelName: modelName,
})
}

return nil
})

return c.OutputFunc(&out, err)
}

func init() {
Expand Down
16 changes: 16 additions & 0 deletions cmd/aiservices/deployment/deployment_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

exocmd "github.com/exoscale/cli/cmd"
"github.com/exoscale/cli/pkg/globalstate"
"github.com/exoscale/cli/pkg/output"
v3 "github.com/exoscale/egoscale/v3"
"github.com/exoscale/egoscale/v3/credentials"
)
Expand Down Expand Up @@ -68,6 +69,21 @@ func TestDeploymentList(t *testing.T) {
{ID: v3.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Name: "d2", Status: v3.ListDeploymentsResponseEntryStatusCreating, GpuType: "gpua5000", GpuCount: 2, Replicas: 1, ServiceLevel: "pro", DeploymentURL: "", Model: nil, CreatedAT: now, UpdatedAT: now},
}
cmd := &DeploymentListCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()}
cmd.OutputFunc = func(out output.Outputter, err error) error {
if err != nil {
return err
}
o := out.(*DeploymentListOutput)
if len(*o) != 2 {
t.Fatalf("expected 2 deployments, got %d", len(*o))
}
for _, d := range *o {
if d.Zone != "test-zone" {
t.Errorf("expected zone %q, got %q", "test-zone", d.Zone)
}
}
return nil
}
if err := cmd.CmdRun(nil, nil); err != nil {
t.Fatalf("deployment list: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/aiservices/model/model_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type ModelCreateCmd struct {
Zone v3.ZoneName `cli-short:"z" cli-usage:"zone"`
}

func (c *ModelCreateCmd) CmdAliases() []string { return exocmd.GCreateAlias }
func (c *ModelCreateCmd) CmdAliases() []string { return append(exocmd.GCreateAlias, "download") }
func (c *ModelCreateCmd) CmdShort() string { return "Create AI model (download from Huggingface)" }
func (c *ModelCreateCmd) CmdLong() string {
return "This command creates an AI model by downloading it from Huggingface.\n\n" +
Expand Down
26 changes: 16 additions & 10 deletions cmd/aiservices/model/model_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ type ModelDeleteCmd struct {

_ bool `cli-cmd:"delete"`

IDs []string `cli-arg:"#" cli-usage:"MODEL-ID (UUID)..."`
Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"`
Zone v3.ZoneName `cli-short:"z" cli-usage:"zone"`
Models []string `cli-arg:"#" cli-usage:"ID or NAME..."`
Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"`
Zone v3.ZoneName `cli-short:"z" cli-usage:"zone"`
}

func (c *ModelDeleteCmd) CmdAliases() []string { return exocmd.GDeleteAlias }
func (c *ModelDeleteCmd) CmdShort() string { return "Delete AI model" }
func (c *ModelDeleteCmd) CmdLong() string { return "This command deletes an AI model by its ID." }
func (c *ModelDeleteCmd) CmdLong() string { return "This command deletes an AI model by ID or name." }
func (c *ModelDeleteCmd) CmdPreRun(cmd *cobra.Command, args []string) error {
exocmd.CmdSetZoneFlagFromDefault(cmd)
return exocmd.CliCommandDefaultPreRun(c, cmd, args)
Expand All @@ -35,24 +35,30 @@ func (c *ModelDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error {
return err
}

// Resolve model IDs using the SDK helper
list, err := client.ListModels(ctx)
if err != nil {
return err
}

modelsToDelete := []v3.UUID{}
for _, idStr := range c.IDs {
id, err := v3.ParseUUID(idStr)
for _, modelStr := range c.Models {
entry, err := list.FindListModelsResponseEntry(modelStr)
if err != nil {
if !c.Force {
return fmt.Errorf("invalid model ID %q: %w", idStr, err)
return err
}
fmt.Fprintf(os.Stderr, "warning: invalid model ID %q: %v\n", idStr, err)
fmt.Fprintf(os.Stderr, "warning: %s not found.\n", modelStr)
continue
}

if !c.Force {
if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete model %q?", idStr)) {
if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete model %q?", modelStr)) {
return nil
}
}

modelsToDelete = append(modelsToDelete, id)
modelsToDelete = append(modelsToDelete, entry.ID)
}

var fns []func() error
Expand Down
68 changes: 22 additions & 46 deletions cmd/aiservices/model/model_delete_test.go
Original file line number Diff line number Diff line change
@@ -1,66 +1,42 @@
package model

import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"testing"

exocmd "github.com/exoscale/cli/cmd"
"github.com/exoscale/cli/pkg/globalstate"
v3 "github.com/exoscale/egoscale/v3"
"github.com/exoscale/egoscale/v3/credentials"
)

func newModelDeleteServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/ai/model/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
writeJSON(t, w, http.StatusOK, v3.Operation{ID: v3.UUID("op-model-delete"), State: v3.OperationStateSuccess})
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
})
mux.HandleFunc("/operation/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
writeJSON(t, w, http.StatusOK, v3.Operation{ID: v3.UUID("op-model-delete"), State: v3.OperationStateSuccess})
})
return httptest.NewServer(mux)
}

func TestModelDeleteInvalidUUIDAndSuccess(t *testing.T) {
srv := newModelDeleteServer(t)
defer srv.Close()
exocmd.GContext = context.Background()
globalstate.Quiet = true
creds := credentials.NewStaticCredentials("key", "secret")
client, err := v3.NewClient(creds)
if err != nil {
t.Fatalf("new client: %v", err)
func TestModelDelete(t *testing.T) {
ts := newModelTestServer(t)
defer modelSetup(t, ts)()
ts.models = []v3.ListModelsResponseEntry{
{ID: v3.UUID("11111111-1111-1111-1111-111111111111"), Name: "m1"},
{ID: v3.UUID("22222222-2222-2222-2222-222222222222"), Name: "m2"},
}
globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(srv.URL))

// invalid UUID without force
cmd := &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), IDs: []string{"not-a-uuid"}, Force: false}
if err := cmd.CmdRun(nil, nil); err == nil || !regexp.MustCompile(`invalid model ID`).MatchString(err.Error()) {
t.Fatalf("expected invalid uuid error, got %v", err)
// Not found without force
cmd := &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Models: []string{"not-found"}, Force: false}
if err := cmd.CmdRun(nil, nil); err == nil {
t.Fatal("expected error for not found model without force")
}
// Not found with force (should skip with warning, no error)
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Models: []string{"not-found"}, Force: true}
if err := cmd.CmdRun(nil, nil); err != nil {
t.Fatalf("expected no error with force flag for not found model, got %v", err)
}
// invalid UUID with force (should skip with warning, no error)
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), IDs: []string{"not-a-uuid"}, Force: true}
// Success by ID
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Models: []string{"11111111-1111-1111-1111-111111111111"}, Force: true}
if err := cmd.CmdRun(nil, nil); err != nil {
t.Fatalf("expected no error with force flag, got %v", err)
t.Fatalf("model delete by ID: %v", err)
}
// success
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), IDs: []string{"33333333-3333-3333-3333-333333333333"}, Force: true}
// Success by Name
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Models: []string{"m2"}, Force: true}
if err := cmd.CmdRun(nil, nil); err != nil {
t.Fatalf("model delete: %v", err)
t.Fatalf("model delete by name: %v", err)
}
// multiple models
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), IDs: []string{"33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"}, Force: true}
cmd = &ModelDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Models: []string{"m1", "22222222-2222-2222-2222-222222222222"}, Force: true}
if err := cmd.CmdRun(nil, nil); err != nil {
t.Fatalf("model delete multiple: %v", err)
}
Expand Down
52 changes: 31 additions & 21 deletions cmd/aiservices/model/model_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ import (
"fmt"
"strings"

"github.com/dustin/go-humanize"
exocmd "github.com/exoscale/cli/cmd"
"github.com/exoscale/cli/pkg/globalstate"
"github.com/exoscale/cli/pkg/output"
"github.com/exoscale/cli/utils"
v3 "github.com/exoscale/egoscale/v3"
"github.com/spf13/cobra"
)

type ModelListItemOutput struct {
ID v3.UUID `json:"id"`
Name string `json:"name"`
Zone v3.ZoneName `json:"zone"`
Status v3.ListModelsResponseEntryStatus `json:"status"`
ModelSize *int64 `json:"model_size"`
ModelSize string `json:"model_size" outputLabel:"Size"`
}

type ModelListOutput []ModelListItemOutput
Expand All @@ -41,36 +44,43 @@ Supported output template annotations: %s`,
strings.Join(output.TemplateAnnotations(&ModelListOutput{}), ", "))
}
func (c *ModelListCmd) CmdPreRun(cmd *cobra.Command, args []string) error {
exocmd.CmdSetZoneFlagFromDefault(cmd)
return exocmd.CliCommandDefaultPreRun(c, cmd, args)
}
func (c *ModelListCmd) CmdRun(_ *cobra.Command, _ []string) error {
ctx := exocmd.GContext
client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone)
if err != nil {
return err
}
resp, err := client.ListModels(ctx)
client := globalstate.EgoscaleV3Client

zones, err := utils.AllZonesV3(ctx, client, c.Zone)
if err != nil {
return err
}

out := make(ModelListOutput, 0, len(resp.Models))
for _, m := range resp.Models {
var sizePtr *int64
if m.ModelSize != 0 {
size := m.ModelSize
sizePtr = &size
out := make(ModelListOutput, 0)
err = utils.ForEveryZone(zones, func(zone v3.Zone) error {
c := client.WithEndpoint(zone.APIEndpoint)
resp, err := c.ListModels(ctx)
if err != nil {
return err
}
out = append(out, ModelListItemOutput{
ID: m.ID,
Name: m.Name,
Status: m.Status,
ModelSize: sizePtr,
})
}

return c.OutputFunc(&out, nil)
for _, m := range resp.Models {
var size string
if m.ModelSize != 0 {
size = humanize.IBytes(uint64(m.ModelSize))
}
out = append(out, ModelListItemOutput{
ID: m.ID,
Name: m.Name,
Zone: zone.Name,
Status: m.Status,
ModelSize: size,
})
}

return nil
})

return c.OutputFunc(&out, err)
}

func init() {
Expand Down
Loading