diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0f046f..a962cefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/aiservices/deployment/deployment_list.go b/cmd/aiservices/deployment/deployment_list.go index 01f9a686..b8354a41 100644 --- a/cmd/aiservices/deployment/deployment_list.go +++ b/cmd/aiservices/deployment/deployment_list.go @@ -7,6 +7,7 @@ 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" ) @@ -14,6 +15,7 @@ import ( 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"` @@ -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() { diff --git a/cmd/aiservices/deployment/deployment_list_test.go b/cmd/aiservices/deployment/deployment_list_test.go index 3aa5258e..03ee5bd4 100644 --- a/cmd/aiservices/deployment/deployment_list_test.go +++ b/cmd/aiservices/deployment/deployment_list_test.go @@ -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" ) @@ -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) } diff --git a/cmd/aiservices/model/model_create.go b/cmd/aiservices/model/model_create.go index 2a7e5cfa..2adc9d18 100644 --- a/cmd/aiservices/model/model_create.go +++ b/cmd/aiservices/model/model_create.go @@ -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" + diff --git a/cmd/aiservices/model/model_delete.go b/cmd/aiservices/model/model_delete.go index 328bc7b0..488f8b05 100644 --- a/cmd/aiservices/model/model_delete.go +++ b/cmd/aiservices/model/model_delete.go @@ -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) @@ -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 diff --git a/cmd/aiservices/model/model_delete_test.go b/cmd/aiservices/model/model_delete_test.go index 1db18694..218e3db4 100644 --- a/cmd/aiservices/model/model_delete_test.go +++ b/cmd/aiservices/model/model_delete_test.go @@ -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) } diff --git a/cmd/aiservices/model/model_list.go b/cmd/aiservices/model/model_list.go index 5f7d31d7..e5b32051 100644 --- a/cmd/aiservices/model/model_list.go +++ b/cmd/aiservices/model/model_list.go @@ -4,9 +4,11 @@ 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" ) @@ -14,8 +16,9 @@ import ( 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 @@ -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() { diff --git a/cmd/aiservices/model/model_list_test.go b/cmd/aiservices/model/model_list_test.go index fbe0b414..6fcc68aa 100644 --- a/cmd/aiservices/model/model_list_test.go +++ b/cmd/aiservices/model/model_list_test.go @@ -79,9 +79,30 @@ func TestModelList(t *testing.T) { now := time.Now() ts.models = []v3.ListModelsResponseEntry{ {ID: v3.UUID("11111111-1111-1111-1111-111111111111"), Name: "m1", Status: v3.ListModelsResponseEntryStatusReady, ModelSize: 0, CreatedAT: now, UpdatedAT: now}, - {ID: v3.UUID("22222222-2222-2222-2222-222222222222"), Name: "m2", Status: v3.ListModelsResponseEntryStatusCreating, ModelSize: 1234, CreatedAT: now, UpdatedAT: now}, + {ID: v3.UUID("22222222-2222-2222-2222-222222222222"), Name: "m2", Status: v3.ListModelsResponseEntryStatusCreating, ModelSize: 1024 * 1024 * 1024, CreatedAT: now, UpdatedAT: now}, } cmd := &ModelListCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + cmd.OutputFunc = func(out output.Outputter, err error) error { + if err != nil { + return err + } + o := out.(*ModelListOutput) + if len(*o) != 2 { + t.Fatalf("expected 2 models, got %d", len(*o)) + } + for _, m := range *o { + if m.Zone != "test-zone" { + t.Errorf("expected zone %q, got %q", "test-zone", m.Zone) + } + if m.Name == "m1" && m.ModelSize != "" { + t.Errorf("expected m1 size empty, got %q", m.ModelSize) + } + if m.Name == "m2" && m.ModelSize != "1.0 GiB" { + t.Errorf("expected m2 size 1.0 GiB, got %q", m.ModelSize) + } + } + return nil + } if err := cmd.CmdRun(nil, nil); err != nil { t.Fatalf("model list: %v", err) } diff --git a/cmd/aiservices/model/model_show.go b/cmd/aiservices/model/model_show.go index f3b0eb7f..05ca93a5 100644 --- a/cmd/aiservices/model/model_show.go +++ b/cmd/aiservices/model/model_show.go @@ -1,9 +1,9 @@ package model import ( - "fmt" "time" + "github.com/dustin/go-humanize" exocmd "github.com/exoscale/cli/cmd" "github.com/exoscale/cli/pkg/globalstate" "github.com/exoscale/cli/pkg/output" @@ -15,7 +15,7 @@ type ModelShowOutput struct { ID v3.UUID `json:"id"` Name string `json:"name"` Status v3.GetModelResponseStatus `json:"status"` - ModelSize *int64 `json:"model_size"` + ModelSize string `json:"model_size" outputLabel:"Size"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -29,14 +29,14 @@ type ModelShowCmd struct { _ bool `cli-cmd:"show"` - ID string `cli-arg:"#" cli-usage:"MODEL-ID (UUID)"` - Zone v3.ZoneName `cli-short:"z" cli-usage:"zone"` + Model string `cli-arg:"#" cli-usage:"ID or NAME"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"zone"` } func (c *ModelShowCmd) CmdAliases() []string { return exocmd.GShowAlias } func (c *ModelShowCmd) CmdShort() string { return "Show AI model" } func (c *ModelShowCmd) CmdLong() string { - return "This command shows details of an AI model by its ID." + return "This command shows details of an AI model by ID or name." } func (c *ModelShowCmd) CmdPreRun(cmd *cobra.Command, args []string) error { exocmd.CmdSetZoneFlagFromDefault(cmd) @@ -49,24 +49,30 @@ func (c *ModelShowCmd) CmdRun(_ *cobra.Command, _ []string) error { return err } - id, err := v3.ParseUUID(c.ID) + // Resolve model ID using the SDK helper + list, err := client.ListModels(ctx) if err != nil { - return fmt.Errorf("invalid model ID: %w", err) + return err } + entry, err := list.FindListModelsResponseEntry(c.Model) + if err != nil { + return err + } + id := entry.ID + resp, err := client.GetModel(ctx, id) if err != nil { return err } - var sizePtr *int64 + var size string if resp.ModelSize != 0 { - size := resp.ModelSize - sizePtr = &size + size = humanize.IBytes(uint64(resp.ModelSize)) } out := &ModelShowOutput{ ID: resp.ID, Name: resp.Name, Status: resp.Status, - ModelSize: sizePtr, + ModelSize: size, CreatedAt: resp.CreatedAT.Format(time.RFC3339), UpdatedAt: resp.UpdatedAT.Format(time.RFC3339), } diff --git a/cmd/aiservices/model/model_show_test.go b/cmd/aiservices/model/model_show_test.go index fc4776bd..d34c10d1 100644 --- a/cmd/aiservices/model/model_show_test.go +++ b/cmd/aiservices/model/model_show_test.go @@ -111,9 +111,16 @@ func TestModelShow(t *testing.T) { ts := newModelTestServer(t) defer modelSetup(t, ts)() now := time.Now() - ts.models = []v3.ListModelsResponseEntry{{ID: v3.UUID("11111111-1111-1111-1111-111111111111"), Name: "m1", Status: v3.ListModelsResponseEntryStatusReady, ModelSize: 123, CreatedAT: now, UpdatedAT: now}} + ts.models = []v3.ListModelsResponseEntry{{ + ID: v3.UUID("11111111-1111-1111-1111-111111111111"), + Name: "m1", + Status: v3.ListModelsResponseEntryStatusReady, + ModelSize: 1024 * 1024 * 1024 * 2, + CreatedAT: now, + UpdatedAT: now, + }} - cmd := &ModelShowCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), ID: "11111111-1111-1111-1111-111111111111"} + cmd := &ModelShowCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Model: "11111111-1111-1111-1111-111111111111"} var got ModelShowOutput cmd.OutputFunc = func(o output.Outputter, err error) error { if err != nil { @@ -128,4 +135,23 @@ func TestModelShow(t *testing.T) { if string(got.ID) != "11111111-1111-1111-1111-111111111111" || got.Name != "m1" || got.Status != v3.GetModelResponseStatusReady { t.Fatalf("unexpected model show output: %+v", got) } + if got.ModelSize != "2.0 GiB" { + t.Errorf("expected model size 2.0 GiB, got %q", got.ModelSize) + } + + // Test show by name + cmd = &ModelShowCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), Model: "m1"} + cmd.OutputFunc = func(o output.Outputter, err error) error { + if err != nil { + return err + } + got = *(o.(*ModelShowOutput)) + return nil + } + if err := cmd.CmdRun(nil, nil); err != nil { + t.Fatalf("model show by name: %v", err) + } + if string(got.ID) != "11111111-1111-1111-1111-111111111111" || got.Name != "m1" { + t.Fatalf("unexpected model show output (by name): %+v", got) + } }