feat(llm): add llm_sku clone API and climc support (#25444)

This commit is contained in:
Zexi Li
2026-08-27 19:01:15 +08:00
committed by GitHub
parent 8abb4057f1
commit 69f9ff2a61
5 changed files with 344 additions and 3 deletions
+1
View File
@@ -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{})
}
+7 -3
View File
@@ -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"`
+149
View File
@@ -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
}
+159
View File
@@ -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)
}
}
+28
View File
@@ -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
}