Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/apis/llm/sku.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ type LLMSkuUpdateInput struct {
ModelScopeModelId *string `json:"model_scope_model_id,omitempty"`
ModelScopeFilePath *string `json:"model_scope_file_path,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
// PreferHosts updates SKU prefer_hosts. Omitted means unchanged; an explicit
// value is required for local_path SKUs and rejected otherwise.
PreferHosts []string `json:"prefer_hosts"`
// Model categories
Categories *[]string `json:"categories,omitempty"`
// Inference backend version and parameters
Expand Down
42 changes: 42 additions & 0 deletions pkg/llm/models/llm_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,48 @@ func AppendLLMSkuVolumeMounts(containers []*computeapi.PodContainerCreateInput,
}
}

// AppendLLMSkuEnvs merges SKU envs into the primary container (index 0).
// Same-key SKU values override driver defaults. Empty keys are ignored.
func AppendLLMSkuEnvs(containers []*computeapi.PodContainerCreateInput, skuBase *SLLMSkuBase) {
if skuBase == nil || skuBase.Envs == nil || skuBase.Envs.IsZero() {
return
}
if len(containers) == 0 || containers[0] == nil {
return
}
containers[0].Envs = mergeContainerEnvs(containers[0].Envs, *skuBase.Envs)
}

func mergeContainerEnvs(existing []*apis.ContainerKeyValue, skuEnvs api.Envs) []*apis.ContainerKeyValue {
indexByKey := make(map[string]int, len(existing))
out := make([]*apis.ContainerKeyValue, 0, len(existing)+len(skuEnvs))
for _, e := range existing {
if e == nil {
continue
}
key := strings.TrimSpace(e.Key)
if key == "" {
continue
}
indexByKey[key] = len(out)
out = append(out, e)
}
for _, e := range skuEnvs {
key := strings.TrimSpace(e.Key)
if key == "" {
continue
}
kv := &apis.ContainerKeyValue{Key: key, Value: e.Value}
if idx, ok := indexByKey[key]; ok {
out[idx] = kv
continue
}
indexByKey[key] = len(out)
out = append(out, kv)
}
return out
}

// 取消自动删除
func (llm *SLLMBase) Delete(ctx context.Context, userCred mcclient.TokenCredential) error {
return nil
Expand Down
87 changes: 87 additions & 0 deletions pkg/llm/models/llm_base_envs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package models

import (
"testing"

"yunion.io/x/onecloud/pkg/apis"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
)

func TestAppendLLMSkuEnvsNilSku(t *testing.T) {
primary := &computeapi.PodContainerCreateInput{
ContainerSpec: computeapi.ContainerSpec{
ContainerSpec: apis.ContainerSpec{
Envs: []*apis.ContainerKeyValue{{Key: "HF_ENDPOINT", Value: "https://hf.co"}},
},
},
}
containers := []*computeapi.PodContainerCreateInput{primary}
AppendLLMSkuEnvs(containers, nil)
if len(primary.Envs) != 1 || primary.Envs[0].Key != "HF_ENDPOINT" {
t.Fatalf("expected driver envs unchanged, got %#v", primary.Envs)
}
}

func TestAppendLLMSkuEnvsEmptySkipped(t *testing.T) {
primary := &computeapi.PodContainerCreateInput{
ContainerSpec: computeapi.ContainerSpec{
ContainerSpec: apis.ContainerSpec{
Envs: []*apis.ContainerKeyValue{{Key: "HF_ENDPOINT", Value: "https://hf.co"}},
},
},
}
empty := api.Envs{}
sku := &SLLMSkuBase{Envs: &empty}
AppendLLMSkuEnvs([]*computeapi.PodContainerCreateInput{primary}, sku)
if len(primary.Envs) != 1 || primary.Envs[0].Value != "https://hf.co" {
t.Fatalf("expected empty sku envs to skip, got %#v", primary.Envs)
}
}

func TestAppendLLMSkuEnvsInjectsPrimaryAndOverrides(t *testing.T) {
primary := &computeapi.PodContainerCreateInput{
ContainerSpec: computeapi.ContainerSpec{
ContainerSpec: apis.ContainerSpec{
Envs: []*apis.ContainerKeyValue{
{Key: "HF_ENDPOINT", Value: "https://hf.co"},
{Key: "FOO", Value: "bar"},
},
},
},
}
sidecar := &computeapi.PodContainerCreateInput{
ContainerSpec: computeapi.ContainerSpec{
ContainerSpec: apis.ContainerSpec{
Envs: []*apis.ContainerKeyValue{{Key: "KEEP", Value: "me"}},
},
},
}
skuEnvs := api.Envs{
{Key: "HF_ENDPOINT", Value: "https://mirror.example"},
{Key: " ", Value: "ignored"},
{Key: "NEW_KEY", Value: "new-val"},
}
sku := &SLLMSkuBase{Envs: &skuEnvs}
AppendLLMSkuEnvs([]*computeapi.PodContainerCreateInput{primary, sidecar}, sku)

if len(primary.Envs) != 3 {
t.Fatalf("expected 3 primary envs, got %#v", primary.Envs)
}
got := map[string]string{}
for _, e := range primary.Envs {
got[e.Key] = e.Value
}
if got["HF_ENDPOINT"] != "https://mirror.example" {
t.Fatalf("expected HF_ENDPOINT override, got %#v", got)
}
if got["FOO"] != "bar" {
t.Fatalf("expected FOO preserved, got %#v", got)
}
if got["NEW_KEY"] != "new-val" {
t.Fatalf("expected NEW_KEY injected, got %#v", got)
}
if len(sidecar.Envs) != 1 || sidecar.Envs[0].Key != "KEEP" {
t.Fatalf("expected sidecar envs unchanged, got %#v", sidecar.Envs)
}
}
1 change: 1 addition & 0 deletions pkg/llm/models/llm_container_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ func GetDriverPodContainers(ctx context.Context, drv ILLMContainerDriver, llm *S
llmBase = &llm.SLLMBase
}
AppendLLMSkuVolumeMounts(containers, llmBase, &sku.SLLMSkuBase, nil)
AppendLLMSkuEnvs(containers, &sku.SLLMSkuBase)
}
return containers
}
4 changes: 4 additions & 0 deletions pkg/llm/models/llm_sku.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,10 @@ func (sku *SLLMSku) ValidateUpdateData(ctx context.Context, userCred mcclient.To
}
}

if err := validateLocalPathSkuUpdatePreferHosts(ctx, userCred, sku, &input); err != nil {
return input, err
}

if sku.LLMSpec == nil {
return input, nil
}
Expand Down
28 changes: 28 additions & 0 deletions pkg/llm/models/llm_sku_local_path.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package models

import (
"context"
"fmt"
"path"
"strings"
Expand All @@ -10,6 +11,7 @@ import (
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
)

func isLocalPathSkuCreate(input *api.LLMSkuCreateInput) bool {
Expand Down Expand Up @@ -52,6 +54,32 @@ func ValidateLocalPathSkuCreate(input *api.LLMSkuCreateInput) error {
return nil
}

// validateLocalPathSkuUpdatePreferHosts validates prefer_hosts on SKU update.
// Omitted PreferHosts (nil) leaves the stored list unchanged. An explicit empty
// list is rejected for local_path SKUs; non-local_path SKUs may not set the field.
func validateLocalPathSkuUpdatePreferHosts(
ctx context.Context,
userCred mcclient.TokenCredential,
sku *SLLMSku,
input *api.LLMSkuUpdateInput,
) error {
if input == nil || input.PreferHosts == nil {
return nil
}
if sku == nil || !SkuHasLocalHostPathModel(sku) {
return errors.Wrap(httperrors.ErrInputParameter, "prefer_hosts can only be updated on local_path SKU")
}
if len(normalizePreferHostInputs(input.PreferHosts)) == 0 {
return errors.Wrap(httperrors.ErrMissingParameter, "prefer_hosts is required for local_path source")
}
resolved, err := resolvePreferHosts(ctx, userCred, input.PreferHosts)
if err != nil {
return err
}
input.PreferHosts = resolved
return nil
}

func hostPathsHasContainerMount(paths api.HostPaths, containerIndex int) bool {
key := fmt.Sprintf("%d", containerIndex)
for _, hp := range paths {
Expand Down
52 changes: 52 additions & 0 deletions pkg/llm/models/llm_sku_local_path_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package models

import (
"context"
"testing"

"yunion.io/x/onecloud/pkg/apis/llm"
Expand Down Expand Up @@ -223,3 +224,54 @@ func TestValidateRequireMountedModelsSkipsLocalPathSku(t *testing.T) {
t.Fatalf("expected mounted_models not required for local_path sku, got %v", err)
}
}

func localPathSkuForPreferHostsUpdate() *SLLMSku {
hostPaths := llm.HostPaths{
{
Type: "directory",
Path: "/data/models/Qwen3-8B",
Containers: llm.ContainerHostPathRelations{
"0": {MountPath: "/data/models/huggingface/Qwen3-8B"},
},
},
}
sku := &SLLMSku{
LLMType: string(llm.LLM_CONTAINER_VLLM),
Source: llm.LLM_MODEL_SOURCE_LOCAL_PATH,
LocalPath: "/data/models/Qwen3-8B",
PreferHosts: []string{"host-1"},
}
sku.HostPaths = &hostPaths
return sku
}

func TestValidateLocalPathSkuUpdatePreferHostsOmitted(t *testing.T) {
sku := localPathSkuForPreferHostsUpdate()
input := &llm.LLMSkuUpdateInput{}
if err := validateLocalPathSkuUpdatePreferHosts(context.Background(), nil, sku, input); err != nil {
t.Fatalf("expected omitted prefer_hosts to skip, got %v", err)
}
if input.PreferHosts != nil {
t.Fatalf("expected prefer_hosts to stay omitted, got %v", input.PreferHosts)
}
}

func TestValidateLocalPathSkuUpdatePreferHostsEmptyRejected(t *testing.T) {
sku := localPathSkuForPreferHostsUpdate()
input := &llm.LLMSkuUpdateInput{PreferHosts: []string{}}
if err := validateLocalPathSkuUpdatePreferHosts(context.Background(), nil, sku, input); err == nil {
t.Fatal("expected error when prefer_hosts is empty")
}
input.PreferHosts = []string{" ", ""}
if err := validateLocalPathSkuUpdatePreferHosts(context.Background(), nil, sku, input); err == nil {
t.Fatal("expected error when prefer_hosts is whitespace only")
}
}

func TestValidateLocalPathSkuUpdatePreferHostsRejectedOnNonLocalPath(t *testing.T) {
sku := &SLLMSku{LLMType: string(llm.LLM_CONTAINER_VLLM)}
input := &llm.LLMSkuUpdateInput{PreferHosts: []string{"host-1"}}
if err := validateLocalPathSkuUpdatePreferHosts(context.Background(), nil, sku, input); err == nil {
t.Fatal("expected error when prefer_hosts is set on non local_path sku")
}
}
Loading