diff --git a/cmd/climc/shell/llm/llm_sku.go b/cmd/climc/shell/llm/llm_sku.go index 3d6cb4db30..a52e2c31f6 100644 --- a/cmd/climc/shell/llm/llm_sku.go +++ b/cmd/climc/shell/llm/llm_sku.go @@ -17,5 +17,6 @@ func init() { cmd.Delete(new(options.LLMSkuDeleteOptions)) cmd.Perform("public", &base_options.BasePublicOptions{}) cmd.Perform("private", &base_options.BaseIdOptions{}) + cmd.Perform("clone", &options.LLMSkuCloneOptions{}) cmd.Perform("schedulable-check", &options.LLMSkuSchedulableCheckOptions{}) } diff --git a/pkg/apis/llm/sku.go b/pkg/apis/llm/sku.go index 219650dfdf..7e2453f2cd 100644 --- a/pkg/apis/llm/sku.go +++ b/pkg/apis/llm/sku.go @@ -261,9 +261,13 @@ type LLMSkuUpdateInput struct { BackendParameters *[]string `json:"backend_parameters,omitempty"` } -// type LLMModelCloneInput struct { -// Name string `json:"name"` -// } +// LLMSkuCloneInput is the body for POST /llm_skus/{id}/clone. +// Specs are copied from the source SKU; the caller supplies a new name. +type LLMSkuCloneInput struct { + Name string `json:"name"` + GenerateName string `json:"generate_name"` + Description string `json:"description"` +} // type LLMModelSyncImageRequestTaskInput struct { // Request bool `json:"request"` diff --git a/pkg/llm/models/llm_sku_clone.go b/pkg/llm/models/llm_sku_clone.go new file mode 100644 index 0000000000..6fff3f9c48 --- /dev/null +++ b/pkg/llm/models/llm_sku_clone.go @@ -0,0 +1,149 @@ +package models + +import ( + "context" + "strings" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + api "yunion.io/x/onecloud/pkg/apis/llm" + "yunion.io/x/onecloud/pkg/cloudcommon/db" + "yunion.io/x/onecloud/pkg/cloudcommon/db/lockman" + "yunion.io/x/onecloud/pkg/httperrors" + "yunion.io/x/onecloud/pkg/mcclient" + "yunion.io/x/onecloud/pkg/util/logclient" +) + +func validateLLMSkuCloneable(sku *SLLMSku) error { + if sku == nil { + return httperrors.NewInputParameterError("empty llm_sku") + } + switch sku.Status { + case api.LLM_DEPLOYMENT_STATUS_IMPORTING_MODEL: + return httperrors.NewInvalidStatusError("cannot clone sku while importing model") + case api.LLM_DEPLOYMENT_STATUS_IMPORT_MODEL_FAILED: + return httperrors.NewInvalidStatusError("cannot clone sku after import model failed") + } + return nil +} + +func buildLLMSkuCloneCreateInput(sku *SLLMSku, input api.LLMSkuCloneInput) (*api.LLMSkuCreateInput, error) { + if err := validateLLMSkuCloneable(sku); err != nil { + return nil, err + } + name := strings.TrimSpace(input.Name) + generateName := strings.TrimSpace(input.GenerateName) + if name == "" { + name = generateName + } + if name == "" { + return nil, httperrors.NewMissingParameterError("name") + } + + create := &api.LLMSkuCreateInput{ + LLMSKuBaseCreateInput: api.LLMSKuBaseCreateInput{ + Cpu: sku.Cpu, + Memory: sku.Memory, + Bandwidth: sku.Bandwidth, + Volumes: cloneJSONPtr(sku.Volumes), + HostPaths: cloneJSONPtr(sku.HostPaths), + PortMappings: cloneJSONPtr(sku.PortMappings), + Devices: cloneJSONPtr(sku.Devices), + Envs: cloneJSONPtr(sku.Envs), + Properties: cloneStringMap(sku.Properties), + }, + LLMImageId: sku.LLMImageId, + LLMType: sku.LLMType, + LLMSpec: cloneJSONPtr(sku.LLMSpec), + Source: sku.Source, + HuggingfaceRepoId: sku.HuggingfaceRepoId, + HuggingfaceFilename: sku.HuggingfaceFilename, + ModelScopeModelId: sku.ModelScopeModelId, + ModelScopeFilePath: sku.ModelScopeFilePath, + LocalPath: sku.LocalPath, + PreferHosts: cloneStringSlice(sku.PreferHosts), + Categories: cloneStringSlice(sku.Categories), + BackendVersion: sku.BackendVersion, + BackendParameters: cloneStringSlice(sku.BackendParameters), + } + create.Name = name + if generateName != "" { + create.GenerateName = generateName + } else { + create.GenerateName = name + } + if desc := strings.TrimSpace(input.Description); desc != "" { + create.Description = desc + } else { + create.Description = sku.Description + } + create.MountedModels = cloneStringSlice(sku.MountedModels) + return create, nil +} + +func (sku *SLLMSku) PerformClone(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.LLMSkuCloneInput) (jsonutils.JSONObject, error) { + createInput, err := buildLLMSkuCloneCreateInput(sku, input) + if err != nil { + return nil, err + } + cloned, err := GetLLMSkuManager().createFromClone(ctx, userCred, createInput) + if err != nil { + return nil, err + } + return jsonutils.Marshal(cloned), nil +} + +func (man *SLLMSkuManager) createFromClone(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*SLLMSku, error) { + data := jsonutils.Marshal(input) + obj, err := db.DoCreate(man, ctx, userCred, nil, data, userCred) + if err != nil { + return nil, errors.Wrap(err, "DoCreate cloned llm_sku") + } + cloned := obj.(*SLLMSku) + func() { + lockman.LockObject(ctx, cloned) + defer lockman.ReleaseObject(ctx, cloned) + cloned.PostCreate(ctx, userCred, userCred, nil, data) + if err := man.GetExtraHook().AfterPostCreate(ctx, userCred, userCred, cloned, nil, data); err != nil { + logclient.AddActionLogWithContext(ctx, cloned, logclient.ACT_POST_CREATE_HOOK, err, userCred, false) + } + }() + notes := cloned.GetShortDesc(ctx) + db.OpsLog.LogEvent(cloned, db.ACT_CREATE, notes, userCred) + logclient.AddActionLogWithContext(ctx, cloned, logclient.ACT_CLONE, notes, userCred, true) + man.OnCreateComplete(ctx, []db.IModel{cloned}, userCred, userCred, nil, []jsonutils.JSONObject{data}) + return cloned, nil +} + +func cloneJSONPtr[T any](src *T) *T { + if src == nil { + return nil + } + dst := new(T) + if err := jsonutils.Marshal(src).Unmarshal(dst); err != nil { + copied := *src + return &copied + } + return dst +} + +func cloneStringSlice(src []string) []string { + if src == nil { + return nil + } + dst := make([]string, len(src)) + copy(dst, src) + return dst +} + +func cloneStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/pkg/llm/models/llm_sku_clone_test.go b/pkg/llm/models/llm_sku_clone_test.go new file mode 100644 index 0000000000..fca571c2b1 --- /dev/null +++ b/pkg/llm/models/llm_sku_clone_test.go @@ -0,0 +1,159 @@ +package models + +import ( + "testing" + + api "yunion.io/x/onecloud/pkg/apis/llm" +) + +func sampleCloneSku() *SLLMSku { + devices := api.Devices{ + {DevType: "GPU", SharingMode: "hami", Model: "NVIDIA-L20", MemoryMb: 20480}, + } + volumes := api.Volumes{ + {SizeMB: 51200}, + } + sku := &SLLMSku{ + SLLMSkuBase: SLLMSkuBase{ + Cpu: 8, + Memory: 16384, + Bandwidth: 200, + Volumes: &volumes, + Devices: &devices, + }, + LLMImageId: "img-1", + LLMType: string(api.LLM_CONTAINER_VLLM), + LLMSpec: &api.LLMSpec{ + Vllm: &api.LLMSpecVllm{PreferredModel: "Qwen3-8B"}, + }, + SMountedModelsResource: SMountedModelsResource{ + MountedModels: []string{"model-1", "model-2"}, + }, + Source: api.LLM_MODEL_SOURCE_LOCAL_PATH, + LocalPath: "/data/models/Qwen3-8B", + PreferHosts: []string{"host-1"}, + Categories: []string{"llm"}, + BackendParameters: []string{ + `--max-model-len=8192`, + }, + } + sku.Description = "src desc" + return sku +} + +func TestBuildLLMSkuCloneCreateInputRejectsEmptyName(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.STATUS_READY + _, err := buildLLMSkuCloneCreateInput(sku, api.LLMSkuCloneInput{}) + if err == nil { + t.Fatal("expected error for empty name") + } +} + +func TestBuildLLMSkuCloneCreateInputRejectsImporting(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.LLM_DEPLOYMENT_STATUS_IMPORTING_MODEL + _, err := buildLLMSkuCloneCreateInput(sku, api.LLMSkuCloneInput{Name: "cloned"}) + if err == nil { + t.Fatal("expected error while importing model") + } +} + +func TestBuildLLMSkuCloneCreateInputRejectsImportFailed(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.LLM_DEPLOYMENT_STATUS_IMPORT_MODEL_FAILED + _, err := buildLLMSkuCloneCreateInput(sku, api.LLMSkuCloneInput{Name: "cloned"}) + if err == nil { + t.Fatal("expected error after import model failed") + } +} + +func TestValidateLLMSkuCloneableImporting(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.LLM_DEPLOYMENT_STATUS_IMPORTING_MODEL + if err := validateLLMSkuCloneable(sku); err == nil { + t.Fatal("expected importing sku to be rejected") + } + sku.Status = api.STATUS_READY + if err := validateLLMSkuCloneable(sku); err != nil { + t.Fatalf("ready sku should be cloneable: %v", err) + } +} + +func TestValidateLLMSkuCloneableImportFailed(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.LLM_DEPLOYMENT_STATUS_IMPORT_MODEL_FAILED + if err := validateLLMSkuCloneable(sku); err == nil { + t.Fatal("expected import-failed sku to be rejected") + } +} + +func TestBuildLLMSkuCloneCreateInputCopiesFields(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.STATUS_READY + create, err := buildLLMSkuCloneCreateInput(sku, api.LLMSkuCloneInput{Name: "cloned-sku"}) + if err != nil { + t.Fatalf("buildLLMSkuCloneCreateInput: %v", err) + } + if create.Name != "cloned-sku" { + t.Fatalf("unexpected name %q", create.Name) + } + if create.GenerateName != "cloned-sku" { + t.Fatalf("unexpected generate_name %q", create.GenerateName) + } + if create.Description != "src desc" { + t.Fatalf("unexpected description %q", create.Description) + } + if create.ModelSpec != nil { + t.Fatal("clone must not set model_spec") + } + if create.Cpu != 8 || create.Memory != 16384 || create.Bandwidth != 200 { + t.Fatalf("resource spec mismatch: cpu=%d mem=%d bw=%d", create.Cpu, create.Memory, create.Bandwidth) + } + if create.LLMImageId != "img-1" || create.LLMType != string(api.LLM_CONTAINER_VLLM) { + t.Fatalf("llm identity mismatch: image=%s type=%s", create.LLMImageId, create.LLMType) + } + if create.LLMSpec == nil || create.LLMSpec.Vllm == nil || create.LLMSpec.Vllm.PreferredModel != "Qwen3-8B" { + t.Fatalf("llm_spec not copied: %+v", create.LLMSpec) + } + if len(create.MountedModels) != 2 || create.MountedModels[0] != "model-1" { + t.Fatalf("mounted_models not copied: %v", create.MountedModels) + } + if create.Source != api.LLM_MODEL_SOURCE_LOCAL_PATH || create.LocalPath != "/data/models/Qwen3-8B" { + t.Fatalf("source not copied: source=%s path=%s", create.Source, create.LocalPath) + } + if len(create.PreferHosts) != 1 || create.PreferHosts[0] != "host-1" { + t.Fatalf("prefer_hosts not copied: %v", create.PreferHosts) + } + if create.Devices == nil || len(*create.Devices) != 1 || (*create.Devices)[0].Model != "NVIDIA-L20" { + t.Fatalf("devices not copied: %+v", create.Devices) + } + if create.Volumes == nil || len(*create.Volumes) != 1 || (*create.Volumes)[0].SizeMB != 51200 { + t.Fatalf("volumes not copied: %+v", create.Volumes) + } + + (*create.Devices)[0].Model = "mutated" + create.MountedModels[0] = "mutated" + create.PreferHosts[0] = "mutated" + if (*sku.Devices)[0].Model != "NVIDIA-L20" { + t.Fatal("clone must deep-copy devices") + } + if sku.MountedModels[0] != "model-1" { + t.Fatal("clone must deep-copy mounted_models") + } + if sku.PreferHosts[0] != "host-1" { + t.Fatal("clone must deep-copy prefer_hosts") + } +} + +func TestBuildLLMSkuCloneCreateInputGenerateName(t *testing.T) { + sku := sampleCloneSku() + sku.Status = api.STATUS_READY + create, err := buildLLMSkuCloneCreateInput(sku, api.LLMSkuCloneInput{GenerateName: "cloned"}) + if err != nil { + t.Fatalf("buildLLMSkuCloneCreateInput: %v", err) + } + if create.Name != "cloned" || create.GenerateName != "cloned" { + t.Fatalf("expected generate_name to fill name, got name=%q generate_name=%q", create.Name, create.GenerateName) + } +} diff --git a/pkg/mcclient/options/llm/llm_sku.go b/pkg/mcclient/options/llm/llm_sku.go index 1ae4db0f05..8b8f5360df 100644 --- a/pkg/mcclient/options/llm/llm_sku.go +++ b/pkg/mcclient/options/llm/llm_sku.go @@ -246,6 +246,34 @@ func (o *LLMSkuUpdateOptions) Params() (jsonutils.JSONObject, error) { return dict, nil } +type LLMSkuCloneOptions struct { + options.BaseIdOptions + NAME string `help:"name of the cloned sku"` + GenerateName string `help:"generate name with auto suffix on conflict" json:"generate_name"` + Description string `help:"description of the cloned sku"` +} + +func (o *LLMSkuCloneOptions) Params() (jsonutils.JSONObject, error) { + name := strings.TrimSpace(o.NAME) + generateName := strings.TrimSpace(o.GenerateName) + if name == "" && generateName == "" { + return nil, errors.Error("name is required") + } + dict := jsonutils.NewDict() + if name != "" { + dict.Set("name", jsonutils.NewString(name)) + } + if generateName != "" { + dict.Set("generate_name", jsonutils.NewString(generateName)) + } else if name != "" { + dict.Set("generate_name", jsonutils.NewString(name)) + } + if strings.TrimSpace(o.Description) != "" { + dict.Set("description", jsonutils.NewString(o.Description)) + } + return dict, nil +} + type LLMSkuSchedulableCheckOptions struct { options.BaseIdOptions }