mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-08-28 19:45:07 +08:00
feat(llm): add llm_sku clone API and climc support (#25444)
This commit is contained in:
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user