feat(llm): create llm_sku from llm_model_set (#24865)

Co-authored-by: cwz <cwz_eikoh@163.com>
This commit is contained in:
Zexi Li
2026-05-21 11:40:37 +08:00
committed by GitHub
parent 383764dbb6
commit c019216508
10 changed files with 350 additions and 26 deletions
+16 -1
View File
@@ -1,6 +1,9 @@
package llm
import (
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/util/printutils"
"yunion.io/x/onecloud/cmd/climc/shell"
modules "yunion.io/x/onecloud/pkg/mcclient/modules/llm"
options "yunion.io/x/onecloud/pkg/mcclient/options/llm"
@@ -11,7 +14,7 @@ func init() {
cmd := shell.NewResourceCmd(&modules.LLMModelSets)
cmd.List(new(options.LLMModelSetListOptions))
cmd.Show(new(options.LLMModelSetShowOptions))
cmd.Get("specs", new(options.LLMModelSetSpecsOptions))
cmd.GetWithCustomShow("specs", printModelSetSpecs, new(options.LLMModelSetSpecsOptions))
cmd.PerformClass("refresh", new(options.LLMModelSetRefreshOptions))
}
{
@@ -19,3 +22,15 @@ func init() {
cmd.Show(new(options.LLMModelSpecShowOptions))
}
}
func printModelSetSpecs(data jsonutils.JSONObject) {
specs, _ := data.GetArray("llm_model_specs")
total, _ := data.Int("total")
if total == 0 {
total = int64(len(specs))
}
shell.PrintList(&printutils.ListResult{
Data: specs,
Total: int(total),
}, []string{"id", "label", "quantization", "mode", "backend", "source", "huggingface_repo_id"})
}
+9 -1
View File
@@ -11,11 +11,15 @@ type LLMCatalogDoc struct {
// (e.g. "Qwen3-8B") that may be deployed via one or more specs.
// Schema mirrors GPUStack `ModelSet` / `ModelSetBase`.
type LLMModelSet struct {
// Resource id exposed to mcclient / climc. The upstream catalog's logical
// key is name, so we mirror it as id for standard resource commands.
Id string `json:"id,omitempty" yaml:"-"`
// Required: globally-unique identifier from YAML. Used as the resource id.
Name string `json:"name" yaml:"name"`
// Optional numeric id assigned by the upstream catalog author.
Id int `json:"id,omitempty" yaml:"id,omitempty"`
CatalogId int `json:"catalog_id,omitempty" yaml:"id,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Order int `json:"order,omitempty" yaml:"order,omitempty"`
Home string `json:"home,omitempty" yaml:"home,omitempty"`
@@ -86,6 +90,10 @@ type LLMModelSpec struct {
// --- Server-assigned, NOT in upstream YAML ---
// Id mirrors SpecId for standard resource-list columns.
Id string `json:"id,omitempty" yaml:"-"`
// Label is a concise display label for climc/UI tables.
Label string `json:"label,omitempty" yaml:"-"`
// SpecId is a synthetic stable id generated at load time so the frontend
// can address a single spec via /llm_model_specs/<id>. Slugified from
// (set name, mode, quantization, backend). Not present in source YAML.
+4
View File
@@ -126,6 +126,7 @@ type LLMSkuDetails struct {
LLMSpec *LLMSpec `json:"llm_spec,omitempty"`
// Model source
LLMModelSpecId string `json:"llm_model_spec_id"`
Source string `json:"source"`
HuggingfaceRepoId string `json:"huggingface_repo_id"`
HuggingfaceFilename string `json:"huggingface_filename"`
@@ -196,6 +197,8 @@ type LLMSkuCreateInput struct {
LLMImageId string `json:"llm_image_id"`
LLMType string `json:"llm_type"`
// LLMModelSpecId references one deployable spec from the in-memory model catalog.
LLMModelSpecId string `json:"llm_model_spec_id"`
// LLMSpec:
// - ollama/vllm: backend builds llm_spec from llm_image_id + mounted_models; for vllm preferred model should be set in llm_spec.vllm.preferred_model.
@@ -228,6 +231,7 @@ type LLMSkuUpdateInput struct {
LLMSpec *LLMSpec `json:"llm_spec,omitempty"`
// Model source
LLMModelSpecId *string `json:"llm_model_spec_id,omitempty"`
Source *string `json:"source,omitempty"`
HuggingfaceRepoId *string `json:"huggingface_repo_id,omitempty"`
HuggingfaceFilename *string `json:"huggingface_filename,omitempty"`
+12
View File
@@ -145,6 +145,7 @@ func (m *SLLMModelSetManager) Refresh(ctx context.Context) error {
if s.Name == "" {
continue
}
s.Id = s.Name
sets = append(sets, s)
setRef := &sets[len(sets)-1]
setsByName[s.Name] = setRef
@@ -161,6 +162,8 @@ func (m *SLLMModelSetManager) Refresh(ctx context.Context) error {
id = fmt.Sprintf("%s-%d", base, seen+1)
}
usedIds[base]++
sp.Id = id
sp.Label = modelSpecLabel(sp, id)
sp.SpecId = id
specsById[id] = &specRef{SetName: setRef.Name, Spec: sp}
totalSpecs++
@@ -179,6 +182,15 @@ func (m *SLLMModelSetManager) Refresh(ctx context.Context) error {
return nil
}
func modelSpecLabel(sp *api.LLMModelSpec, id string) string {
for _, s := range []string{sp.Name, sp.Quantization, sp.Mode, sp.Backend, id} {
if strings.TrimSpace(s) != "" {
return s
}
}
return id
}
// specBaseId composes a slug from the spec's identifying fields. Stable across
// loads as long as the upstream YAML keeps the same (set.name, mode,
// quantization, backend) tuple per spec.
+56 -1
View File
@@ -12,6 +12,7 @@ import (
imageapi "yunion.io/x/onecloud/pkg/apis/image"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/auth"
@@ -57,6 +58,7 @@ type SLLMSku struct {
LLMSpec *api.LLMSpec `json:"llm_spec" length:"long" list:"user" create:"optional" update:"user"`
// Model source
LLMModelSpecId string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
Source string `width:"32" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
HuggingfaceRepoId string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
HuggingfaceFilename string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
@@ -140,6 +142,16 @@ func (manager *SLLMSkuManager) FetchCustomizeColumns(
res[i].SharableVirtualResourceDetails = virows[i]
res[i].LLMType = sku.LLMType
res[i].LLMSpec = sku.LLMSpec
res[i].LLMModelSpecId = sku.LLMModelSpecId
res[i].Source = sku.Source
res[i].HuggingfaceRepoId = sku.HuggingfaceRepoId
res[i].HuggingfaceFilename = sku.HuggingfaceFilename
res[i].ModelScopeModelId = sku.ModelScopeModelId
res[i].ModelScopeFilePath = sku.ModelScopeFilePath
res[i].LocalPath = sku.LocalPath
res[i].Categories = sku.Categories
res[i].BackendVersion = sku.BackendVersion
res[i].BackendParameters = sku.BackendParameters
for _, v := range details {
if v.LLMSkuId == sku.Id {
res[i].LLMCapacity = v.LLMCapacity
@@ -228,10 +240,53 @@ func (man *SLLMSkuManager) ValidateCreateData(ctx context.Context, userCred mccl
if err != nil {
return input, errors.Wrap(err, "validate create input")
}
input.Status = api.STATUS_READY
if _, err := resolveLLMSkuCatalogImport(input); err != nil {
return input, err
}
if input.LLMModelSpecId != "" {
input.Status = api.LLM_DEPLOYMENT_STATUS_IMPORTING_MODEL
} else {
input.Status = api.STATUS_READY
}
return input, nil
}
func (sku *SLLMSku) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
sku.SSharableVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
input := api.LLMSkuCreateInput{}
if data == nil || data.Unmarshal(&input) != nil || input.LLMModelSpecId == "" {
return
}
if err := sku.StartCreateTask(ctx, userCred, data); err != nil {
log.Errorf("SLLMSku.PostCreate start task failed: %s", err)
sku.SetStatus(ctx, userCred, api.LLM_DEPLOYMENT_STATUS_IMPORT_MODEL_FAILED, err.Error())
}
}
func (sku *SLLMSku) StartCreateTask(ctx context.Context, userCred mcclient.TokenCredential, data jsonutils.JSONObject) error {
params, _ := data.(*jsonutils.JSONDict)
if params == nil {
params = jsonutils.NewDict()
}
importInput := api.InstantModelImportInput{}
if err := params.Unmarshal(&importInput, "import_input"); err != nil {
createdInput := api.LLMSkuCreateInput{}
if err := params.Unmarshal(&createdInput); err != nil {
return errors.Wrap(err, "unmarshal LLMSkuCreateInput")
}
derived, err := resolveLLMSkuCatalogImport(&createdInput)
if err != nil {
return errors.Wrap(err, "resolve catalog import")
}
params.Set("import_input", jsonutils.Marshal(derived))
}
task, err := taskman.TaskManager.NewTask(ctx, "LLMSkuCreateTask", sku, userCred, params, "", "", nil)
if err != nil {
return errors.Wrap(err, "NewTask LLMSkuCreateTask")
}
return task.ScheduleRun(nil)
}
// GetLLMImageId returns the primary image id for this SKU. Delegates to driver.
func (sku *SLLMSku) GetLLMImageId() string {
return sku.GetLLMContainerDriver().GetPrimaryImageId(sku)
+136
View File
@@ -0,0 +1,136 @@
package models
import (
"context"
"strings"
"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/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
)
func resolveLLMSkuCatalogImport(input *api.LLMSkuCreateInput) (*api.InstantModelImportInput, error) {
if input == nil || strings.TrimSpace(input.LLMModelSpecId) == "" {
return nil, nil
}
spec, setName, ok := GetLLMModelSetManager().GetSpec(input.LLMModelSpecId)
if !ok {
return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "llm_model_spec %s not found", input.LLMModelSpecId)
}
set, ok := GetLLMModelSetManager().GetSet(setName)
if !ok {
return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "llm_model_set %s not found", setName)
}
return buildLLMSkuCatalogImport(input, set, spec)
}
func catalogBackendToLLMType(backend string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(backend)) {
case "":
return "", true
case "vllm":
return string(api.LLM_CONTAINER_VLLM), true
case "sglang":
return string(api.LLM_CONTAINER_SGLANG), true
case "ollama":
return string(api.LLM_CONTAINER_OLLAMA), true
default:
return "", false
}
}
func buildLLMSkuCatalogImport(input *api.LLMSkuCreateInput, set *api.LLMModelSet, spec *api.LLMModelSpec) (*api.InstantModelImportInput, error) {
if input == nil {
return nil, errors.Wrap(httperrors.ErrInputParameter, "empty sku input")
}
if spec == nil {
return nil, errors.Wrap(httperrors.ErrInputParameter, "empty catalog spec")
}
if !strings.EqualFold(spec.Source, api.LLM_MODEL_SOURCE_HUGGINGFACE) {
return nil, errors.Wrapf(httperrors.ErrInputParameter, "unsupported catalog source %q", spec.Source)
}
if strings.TrimSpace(spec.HuggingfaceRepoId) == "" {
return nil, errors.Wrap(httperrors.ErrMissingParameter, "huggingface_repo_id is required")
}
if expectedType, ok := catalogBackendToLLMType(spec.Backend); !ok {
return nil, errors.Wrapf(httperrors.ErrInputParameter, "unsupported catalog backend %q", spec.Backend)
} else if expectedType != "" && input.LLMType != "" && expectedType != input.LLMType {
return nil, errors.Wrapf(httperrors.ErrInputParameter, "catalog backend %q requires llm_type %q", spec.Backend, expectedType)
}
input.LLMModelSpecId = spec.SpecId
input.Source = spec.Source
input.HuggingfaceRepoId = spec.HuggingfaceRepoId
input.HuggingfaceFilename = spec.HuggingfaceFilename
input.ModelScopeModelId = spec.ModelScopeModelId
input.ModelScopeFilePath = spec.ModelScopeFilePath
input.LocalPath = spec.LocalPath
input.BackendVersion = spec.BackendVersion
input.BackendParameters = append([]string{}, spec.BackendParameters...)
if set != nil {
input.Categories = append([]string{}, set.Categories...)
}
revision := defaultHuggingFaceRevision
return &api.InstantModelImportInput{
ModelName: spec.HuggingfaceRepoId,
ModelTag: revision,
LlmType: api.LLMContainerType(input.LLMType),
Source: api.InstantModelSourceHuggingFace,
RepoId: spec.HuggingfaceRepoId,
Revision: revision,
}, nil
}
func appendMountedModelIds(existing []string, ids ...string) []string {
out := append([]string{}, existing...)
seen := make(map[string]struct{}, len(out)+len(ids))
for _, id := range out {
seen[id] = struct{}{}
}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
out = append(out, id)
seen[id] = struct{}{}
}
return out
}
func EnableInstantModelForUse(ctx context.Context, userCred mcclient.TokenCredential, id string) error {
obj, err := GetInstantModelManager().FetchById(id)
if err != nil {
return errors.Wrapf(err, "fetch InstantModel %s", id)
}
im := obj.(*SInstantModel)
if im.Enabled.IsTrue() {
return nil
}
_, err = db.Update(im, func() error {
im.SetEnabled(true)
return nil
})
if err != nil {
return errors.Wrap(err, "db.Update")
}
return nil
}
func (sku *SLLMSku) AttachMountedModel(ctx context.Context, userCred mcclient.TokenCredential, instantModelId string) error {
_, err := db.Update(sku, func() error {
sku.MountedModels = appendMountedModelIds(sku.MountedModels, instantModelId)
return nil
})
if err != nil {
return errors.Wrap(err, "db.Update")
}
return nil
}
@@ -177,7 +177,7 @@ func (task *LLMDeploymentCreateTask) OnInstantModelReady(ctx context.Context, mo
// Auto-enable the freshly-imported InstantModel — without this it stays
// disabled and FindReadyInstantModel can't dedup it on the next catalog
// deploy, plus the SKU/LLM mount validators depend on enabled rows.
if err := enableInstantModel(ctx, task.UserCred, instantId); err != nil {
if err := models.EnableInstantModelForUse(ctx, task.UserCred, instantId); err != nil {
log.Warningf("LLMDeploymentCreateTask: enable InstantModel %s: %s", instantId, err)
}
@@ -187,28 +187,6 @@ func (task *LLMDeploymentCreateTask) OnInstantModelReady(ctx context.Context, mo
task.createSkuAndReconcile(ctx, model, &skuSpec, task.GetParams())
}
// enableInstantModel flips the InstantModel's enabled flag to true after a
// successful catalog-driven import. Idempotent: skips the update if already
// enabled.
func enableInstantModel(ctx context.Context, userCred mcclient.TokenCredential, id string) error {
obj, err := models.GetInstantModelManager().FetchById(id)
if err != nil {
return errors.Wrapf(err, "fetch InstantModel %s", id)
}
im := obj.(*models.SInstantModel)
if im.Enabled.IsTrue() {
return nil
}
_, err = db.Update(im, func() error {
im.SetEnabled(true)
return nil
})
if err != nil {
return errors.Wrap(err, "db.Update")
}
return nil
}
// OnInstantModelReadyFailed is called if the child InstantModel import task fails.
func (task *LLMDeploymentCreateTask) OnInstantModelReadyFailed(ctx context.Context, model *models.SLLMDeployment, body jsonutils.JSONObject) {
task.taskFailedImporting(ctx, model, fmt.Errorf("InstantModel import failed: %s", body))
+97
View File
@@ -0,0 +1,97 @@
package llm
import (
"context"
"fmt"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"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/taskman"
"yunion.io/x/onecloud/pkg/llm/models"
"yunion.io/x/onecloud/pkg/util/logclient"
)
type LLMSkuCreateTask struct {
taskman.STask
}
func init() {
taskman.RegisterTask(LLMSkuCreateTask{})
}
func (task *LLMSkuCreateTask) taskFailed(ctx context.Context, sku *models.SLLMSku, err error) {
sku.SetStatus(ctx, task.UserCred, api.LLM_DEPLOYMENT_STATUS_IMPORT_MODEL_FAILED, err.Error())
db.OpsLog.LogEvent(sku, db.ACT_CREATE, err, task.UserCred)
logclient.AddActionLogWithStartable(task, sku, logclient.ACT_CREATE, err, task.UserCred, false)
task.SetStageFailed(ctx, jsonutils.NewString(err.Error()))
}
func (task *LLMSkuCreateTask) OnInit(ctx context.Context, obj db.IStandaloneModel, body jsonutils.JSONObject) {
sku := obj.(*models.SLLMSku)
importInput := api.InstantModelImportInput{}
if err := task.GetParams().Unmarshal(&importInput, "import_input"); err != nil {
task.taskFailed(ctx, sku, errors.Wrap(err, "unmarshal import_input"))
return
}
if importInput.LlmType == "" {
importInput.LlmType = api.LLMContainerType(sku.LLMType)
}
if existing, err := models.GetInstantModelManager().FindReadyInstantModel(
string(importInput.LlmType),
importInput.ModelName,
importInput.ModelTag,
); err != nil {
log.Warningf("LLMSkuCreateTask FindReadyInstantModel: %s; importing a fresh InstantModel", err)
} else if existing != nil {
task.completeWithInstantModel(ctx, sku, existing.GetId())
return
}
task.SetStage("OnInstantModelReady", nil)
instantModel, err := models.GetInstantModelManager().DoImportWithParent(ctx, task.UserCred, importInput, task.GetTaskId())
if err != nil {
task.taskFailed(ctx, sku, errors.Wrap(err, "DoImportWithParent"))
return
}
extra := jsonutils.NewDict()
extra.Set("imported_instant_model_id", jsonutils.NewString(instantModel.GetId()))
if err := task.SaveParams(extra); err != nil {
log.Warningf("LLMSkuCreateTask persist imported instant model id: %s", err)
}
}
func (task *LLMSkuCreateTask) OnInstantModelReady(ctx context.Context, sku *models.SLLMSku, body jsonutils.JSONObject) {
instantId, _ := task.GetParams().GetString("imported_instant_model_id")
if instantId == "" {
task.taskFailed(ctx, sku, errors.Error("missing imported_instant_model_id in task params"))
return
}
task.completeWithInstantModel(ctx, sku, instantId)
}
func (task *LLMSkuCreateTask) OnInstantModelReadyFailed(ctx context.Context, sku *models.SLLMSku, body jsonutils.JSONObject) {
task.taskFailed(ctx, sku, fmt.Errorf("InstantModel import failed: %s", body))
}
func (task *LLMSkuCreateTask) completeWithInstantModel(ctx context.Context, sku *models.SLLMSku, instantId string) {
if err := models.EnableInstantModelForUse(ctx, task.UserCred, instantId); err != nil {
task.taskFailed(ctx, sku, errors.Wrapf(err, "enable InstantModel %s", instantId))
return
}
if err := sku.AttachMountedModel(ctx, task.UserCred, instantId); err != nil {
task.taskFailed(ctx, sku, errors.Wrapf(err, "attach InstantModel %s to SKU", instantId))
return
}
if err := sku.SetStatus(ctx, task.UserCred, api.STATUS_READY, "instant model imported"); err != nil {
task.taskFailed(ctx, sku, errors.Wrap(err, "set sku ready"))
return
}
db.OpsLog.LogEvent(sku, db.ACT_CREATE, sku.GetShortDesc(ctx), task.UserCred)
logclient.AddActionLogWithStartable(task, sku, logclient.ACT_CREATE, sku.GetShortDesc(ctx), task.UserCred, true)
task.SetStageComplete(ctx, nil)
}
@@ -1,6 +1,11 @@
package llm
import (
"fmt"
"net/url"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
"yunion.io/x/onecloud/pkg/mcclient/modules"
)
@@ -30,6 +35,19 @@ type LLMModelSetsManager struct {
modulebase.ResourceManager
}
func (m *LLMModelSetsManager) GetSpecific(session *mcclient.ClientSession, id string, spec string, params jsonutils.JSONObject) (jsonutils.JSONObject, error) {
if spec != "specs" {
return m.ResourceManager.GetSpecific(session, id, spec, params)
}
path := fmt.Sprintf("/%s/%s/%s", m.ContextPath(nil), url.PathEscape(id), url.PathEscape(spec))
if params != nil {
if qs := params.QueryString(); qs != "" {
path = fmt.Sprintf("%s?%s", path, qs)
}
}
return modulebase.Get(m.ResourceManager, session, path, "")
}
type LLMModelSpecsManager struct {
modulebase.ResourceManager
}
+1
View File
@@ -37,6 +37,7 @@ type LLMSkuCreateOptions struct {
LLM_TYPE string `json:"llm_type" choices:"ollama|vllm|comfyui|sglang"`
// Model source
LLMModelSpecId string `help:"LLM catalog model spec id; starts InstantModel import and mounts it into this SKU" json:"llm_model_spec_id"`
Source string `help:"model source: huggingface, model_scope, local_path" json:"source"`
HuggingfaceRepoId string `help:"HuggingFace repo ID" json:"huggingface_repo_id"`
HuggingfaceFilename string `help:"HuggingFace filename" json:"huggingface_filename"`