diff --git a/pkg/apis/llm/sku.go b/pkg/apis/llm/sku.go index 25c3fe36d41..df2fea0deae 100644 --- a/pkg/apis/llm/sku.go +++ b/pkg/apis/llm/sku.go @@ -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 diff --git a/pkg/llm/models/llm_base.go b/pkg/llm/models/llm_base.go index 03324315f3a..821e000ebe5 100644 --- a/pkg/llm/models/llm_base.go +++ b/pkg/llm/models/llm_base.go @@ -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 diff --git a/pkg/llm/models/llm_base_envs_test.go b/pkg/llm/models/llm_base_envs_test.go new file mode 100644 index 00000000000..3c278559de4 --- /dev/null +++ b/pkg/llm/models/llm_base_envs_test.go @@ -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) + } +} diff --git a/pkg/llm/models/llm_container_driver.go b/pkg/llm/models/llm_container_driver.go index 0688b0f1dea..40660c7ae78 100644 --- a/pkg/llm/models/llm_container_driver.go +++ b/pkg/llm/models/llm_container_driver.go @@ -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 } diff --git a/pkg/llm/models/llm_sku.go b/pkg/llm/models/llm_sku.go index 0a59a735472..d6ebaeb67dc 100644 --- a/pkg/llm/models/llm_sku.go +++ b/pkg/llm/models/llm_sku.go @@ -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 } diff --git a/pkg/llm/models/llm_sku_local_path.go b/pkg/llm/models/llm_sku_local_path.go index 5a50889c4f3..1d4196d323a 100644 --- a/pkg/llm/models/llm_sku_local_path.go +++ b/pkg/llm/models/llm_sku_local_path.go @@ -1,6 +1,7 @@ package models import ( + "context" "fmt" "path" "strings" @@ -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 { @@ -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 { diff --git a/pkg/llm/models/llm_sku_local_path_test.go b/pkg/llm/models/llm_sku_local_path_test.go index 0e6c5d27232..7ed68b19c55 100644 --- a/pkg/llm/models/llm_sku_local_path_test.go +++ b/pkg/llm/models/llm_sku_local_path_test.go @@ -1,6 +1,7 @@ package models import ( + "context" "testing" "yunion.io/x/onecloud/pkg/apis/llm" @@ -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") + } +}