Files

577 lines
24 KiB
Go

/*
Copyright 2025 The KServe Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package llmisvc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"text/template"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/strategicpatch"
"k8s.io/utils/ptr"
"knative.dev/pkg/kmeta"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
"github.com/kserve/kserve/pkg/constants"
"github.com/kserve/kserve/pkg/utils"
)
// Configuration template name suffixes for different LLM deployment patterns
// These configs are automatically applied based on the service configuration
const (
// Single node deployment template
configTemplateNameSuffix = "config-llm-template"
// Disaggregated prefill/decode templates
configDecodeTemplateNameSuffix = "config-llm-decode-template"
configPrefillTemplateNameSuffix = "config-llm-prefill-template"
// Pipeline parallel worker configurations
configDecodeWorkerPipelineParallelNameSuffix = "config-llm-decode-worker-pipeline-parallel"
configWorkerPipelineParallelNameSuffix = "config-llm-worker-pipeline-parallel"
configPrefillWorkerPipelineParallelNameSuffix = "config-llm-prefill-worker-pipeline-parallel"
// Data parallel worker configurations
configWorkerDataParallelNameSuffix = "config-llm-worker-data-parallel"
configDecodeWorkerDataParallelNameSuffix = "config-llm-decode-worker-data-parallel"
configPrefillWorkerDataParallelNameSuffix = "config-llm-prefill-worker-data-parallel"
// Router and scheduler configurations
configRouterSchedulerNameSuffix = "config-llm-scheduler"
configRouterRouteNameSuffix = "config-llm-router-route"
)
var (
configPrefix = constants.GetEnvOrDefault("LLM_INFERENCE_SERVICE_CONFIG_PREFIX", "kserve-")
configTemplateName = configPrefix + configTemplateNameSuffix
configDecodeTemplateName = configPrefix + configDecodeTemplateNameSuffix
configDecodeWorkerPipelineParallelName = configPrefix + configDecodeWorkerPipelineParallelNameSuffix
configWorkerPipelineParallelName = configPrefix + configWorkerPipelineParallelNameSuffix
configWorkerDataParallelName = configPrefix + configWorkerDataParallelNameSuffix
configDecodeWorkerDataParallelName = configPrefix + configDecodeWorkerDataParallelNameSuffix
configPrefillTemplateName = configPrefix + configPrefillTemplateNameSuffix
configPrefillWorkerPipelineParallelName = configPrefix + configPrefillWorkerPipelineParallelNameSuffix
configPrefillWorkerDataParallelName = configPrefix + configPrefillWorkerDataParallelNameSuffix
configRouterSchedulerName = configPrefix + configRouterSchedulerNameSuffix
configRouterRouteName = configPrefix + configRouterRouteNameSuffix
)
// FIXME move those presets to well-known when they're finally known :)
var _ = sets.New[string](
configPrefillWorkerPipelineParallelName,
configDecodeWorkerPipelineParallelName,
configWorkerPipelineParallelName,
)
// WellKnownDefaultConfigs contains the set of default configuration templates
// that are automatically applied based on the LLM service deployment pattern
var WellKnownDefaultConfigs = sets.New[string](
configTemplateName,
configDecodeTemplateName,
configWorkerDataParallelName,
configDecodeWorkerDataParallelName,
configPrefillTemplateName,
configPrefillWorkerDataParallelName,
configRouterSchedulerName,
configRouterRouteName,
)
const (
precisePrefixCacheScorerName = "precise-prefix-cache-scorer"
)
var useVersionedConfig, _ = strconv.ParseBool(constants.GetEnvOrDefault("LLM_INFERENCE_SERVICE_VERSIONED_CONFIG", "true"))
// CombineOption is a functional option for combineBaseRefsConfig
type CombineOption func(*combineOptions)
type combineOptions struct {
skipClearSchedulerConfigRef bool
}
// WithSkipClearSchedulerConfigRef prevents clearing the scheduler config ref after resolving.
// This is useful when the caller needs to check which ConfigMap was referenced.
func WithSkipClearSchedulerConfigRef() CombineOption {
return func(o *combineOptions) {
o.skipClearSchedulerConfigRef = true
}
}
// combineBaseRefsConfig applies well-known config overlays to inject default values for various components, when some components are
// enabled. These LLMInferenceServiceConfig resources must exist in either resource namespace (prioritized) or
// SystemNamespace (e.g. `kserve`).
// It determines which deployment pattern is being used (single node, multi-node, disaggregated) and applies appropriate defaults.
func (r *LLMISVCReconciler) combineBaseRefsConfig(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, reconcilerConfig *Config, opts ...CombineOption) (*v1alpha2.LLMInferenceServiceConfig, error) {
options := &combineOptions{}
for _, opt := range opts {
opt(options)
}
logger := log.FromContext(ctx).WithName("combineBaseRefsConfig")
wr := &WellKnownConfigResolver{}
wr.Attach(llmSvc)
// Creates the initial spec with the merged BaseRefs, so that we know what's "Enabled".
resolvedSpec := *llmSvc.Spec.DeepCopy()
for _, ref := range llmSvc.Spec.BaseRefs {
cfg, err := r.getConfig(ctx, llmSvc, ref.Name)
if err != nil {
return nil, err
}
if cfg != nil {
var resolvedErr error
resolvedSpec, resolvedErr = mergeSpecs(ctx, resolvedSpec, cfg.Spec)
if resolvedErr != nil {
return nil, fmt.Errorf("failed to merge specs: %w", resolvedErr)
}
}
}
if resolvedSpec.Model.Name != nil {
// If original model name was defaulted check if it was not substituted by baseRef
llmSvc.Spec.Model.Name = resolvedSpec.Model.Name
}
logger.V(2).Info("Resolved spec", "spec", resolvedSpec)
refs := make([]corev1.LocalObjectReference, 0, len(llmSvc.Spec.BaseRefs))
if resolvedSpec.Router != nil && resolvedSpec.Router.Scheduler != nil && !resolvedSpec.Router.Scheduler.Pool.HasRef() {
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configRouterSchedulerName)})
}
if resolvedSpec.Router != nil && resolvedSpec.Router.Route != nil && !resolvedSpec.Router.Route.HTTP.HasRefs() {
// For the HTTP route configuration we don't use versioned defaults since this configuration depends on the
// GW API provider version.
refs = append(refs, corev1.LocalObjectReference{Name: configRouterRouteName})
}
if resolvedSpec.Prefill != nil { // P/D
// Prefill
switch {
case resolvedSpec.Prefill.Worker == nil:
// single-node prefill
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configPrefillTemplateName)})
case resolvedSpec.Prefill.Worker != nil && resolvedSpec.Prefill.Parallelism.IsDataParallel():
// multi-node Data Parallel prefill
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configPrefillWorkerDataParallelName)})
case resolvedSpec.Prefill.Worker != nil && resolvedSpec.Prefill.Parallelism.IsPipelineParallel():
// multi-node Pipeline Parallel prefill
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configPrefillWorkerPipelineParallelName)})
}
// Decode
switch {
case resolvedSpec.Worker == nil:
// single-node decode
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configDecodeTemplateName)})
case resolvedSpec.Worker != nil && resolvedSpec.Parallelism.IsDataParallel():
// multi-node Data Parallel decode
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configDecodeWorkerDataParallelName)})
case resolvedSpec.Worker != nil && resolvedSpec.Parallelism.IsPipelineParallel():
// multi-node Pipeline Parallel decode
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configDecodeWorkerPipelineParallelName)})
}
} else { // Non P/D
switch {
case resolvedSpec.Worker == nil:
// single-node
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configTemplateName)})
case resolvedSpec.Worker != nil && resolvedSpec.Parallelism.IsDataParallel():
// multi-node Data Parallel
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configWorkerDataParallelName)})
case resolvedSpec.Worker != nil && resolvedSpec.Parallelism.IsPipelineParallel():
// multi-node Pipeline Parallel
refs = append(refs, corev1.LocalObjectReference{Name: wr.Resolve(llmSvc, configWorkerPipelineParallelName)})
}
}
// Append explicit base refs to override well know configs.
refs = append(refs, llmSvc.Spec.BaseRefs...)
specs := make([]v1alpha2.LLMInferenceServiceSpec, 0, len(refs))
for _, ref := range refs {
cfg, err := r.getConfig(ctx, llmSvc, ref.Name)
if err != nil {
return nil, err
}
if cfg != nil {
specs = append(specs, cfg.Spec)
}
}
spec, err := MergeSpecs(ctx, append(specs, llmSvc.Spec)...)
if err != nil {
return nil, fmt.Errorf("failed to merge specs: %w", err)
}
llmSvcCfg := &v1alpha2.LLMInferenceServiceConfig{
ObjectMeta: *llmSvc.ObjectMeta.DeepCopy(),
Spec: spec,
}
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Scheduler != nil &&
llmSvcCfg.Spec.Router.Scheduler.Pool != nil &&
llmSvcCfg.Spec.Router.Scheduler.Pool.Spec != nil &&
len(llmSvcCfg.Spec.Router.Scheduler.Pool.Spec.Selector.MatchLabels) == 0 {
selector := GetWorkloadLabelSelector(llmSvc.ObjectMeta, &llmSvcCfg.Spec)
gieSelector := make(map[igwapi.LabelKey]igwapi.LabelValue, len(selector))
for k, v := range selector {
gieSelector[igwapi.LabelKey(k)] = igwapi.LabelValue(v)
}
llmSvcCfg.Spec.Router.Scheduler.Pool.Spec.Selector.MatchLabels = gieSelector
}
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Scheduler != nil &&
llmSvcCfg.Spec.Router.Scheduler.Template != nil &&
llmSvcCfg.Spec.Router.Scheduler.Template.ServiceAccountName == "" {
llmSvcCfg.Spec.Router.Scheduler.Template.ServiceAccountName = kmeta.ChildName(llmSvc.GetName(), "-epp-sa")
}
llmSvcCfg, err = ReplaceVariables(llmSvc, llmSvcCfg, reconcilerConfig)
if err != nil {
return llmSvcCfg, err
}
// Update HTTPRoute parentRefs to point to the custom gateway if Gateway.Refs is specified.
// This ensures the managed HTTPRoute references the correct gateway instead of the default one from presets.
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Route != nil &&
llmSvcCfg.Spec.Router.Route.HTTP.HasSpec() &&
llmSvcCfg.Spec.Router.Gateway.HasRefs() {
llmSvcCfg.Spec.Router.Route.HTTP.Spec.ParentRefs = ToParentRefs(llmSvcCfg.Spec.Router.Gateway.Refs)
}
// Point HTTPRoute to a Service if there is no Scheduler or InferencePool, and the HTTPRoute uses the default
// InferencePool (to handle cases where the HTTPRoute Spec uses a custom BackendRef).
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Route != nil &&
llmSvcCfg.Spec.Router.Route.HTTP.HasSpec() &&
llmSvcCfg.Spec.Router.Scheduler == nil {
for i := range llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules {
for j := range llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs {
if isDefaultBackendRef(llmSvc, llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].BackendRef) {
llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].Group = ptr.To[gwapiv1.Group]("")
llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].Kind = ptr.To[gwapiv1.Kind]("Service")
llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].Name = gwapiv1.ObjectName(kmeta.ChildName(llmSvc.GetName(), "-kserve-workload-svc"))
}
}
}
}
// Point HTTPRoute to InferencePool reference if specified.
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Route != nil &&
llmSvcCfg.Spec.Router.Route.HTTP.HasSpec() &&
llmSvcCfg.Spec.Router.Scheduler != nil &&
llmSvcCfg.Spec.Router.Scheduler.Pool.HasRef() {
for i := range llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules {
for j := range llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs {
if isDefaultBackendRef(llmSvc, llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].BackendRef) {
llmSvcCfg.Spec.Router.Route.HTTP.Spec.Rules[i].BackendRefs[j].Name = gwapiv1.ObjectName(llmSvcCfg.Spec.Router.Scheduler.Pool.Ref.Name)
}
}
}
}
// Resolve the external Scheduler configuration.
if llmSvcCfg.Spec.Router != nil &&
llmSvcCfg.Spec.Router.Scheduler != nil &&
llmSvcCfg.Spec.Router.Scheduler.Config != nil &&
llmSvcCfg.Spec.Router.Scheduler.Config.Ref != nil {
cmName := llmSvcCfg.Spec.Router.Scheduler.Config.Ref.Name
cm, err := Get(ctx, r.Client, client.ObjectKey{Namespace: llmSvc.GetNamespace(), Name: cmName}, &corev1.ConfigMap{}, WithGetFallbackAPIServerConfigMap(r.Clientset))
if err != nil {
if !apierrors.IsNotFound(err) {
return llmSvcCfg, fmt.Errorf("failed to get ConfigMap %s/%s: %w", llmSvc.GetNamespace(), cmName, err)
}
if strings.HasPrefix(cmName, "config-scheduler-") {
cm, err = Get(ctx, r.Client, client.ObjectKey{Namespace: constants.KServeNamespace, Name: cmName}, &corev1.ConfigMap{}, WithGetFallbackAPIServerConfigMap(r.Clientset))
if err != nil {
return nil, fmt.Errorf("failed to get scheduler config %q from namespaces [%q, %q]: %w", cmName, llmSvc.Namespace, constants.KServeNamespace, err)
}
}
}
if llmSvcCfg.Spec.Router.Scheduler.Config.Ref.Key == "" {
llmSvcCfg.Spec.Router.Scheduler.Config.Ref.Key = "epp"
}
cfg, ok := cm.Data[llmSvcCfg.Spec.Router.Scheduler.Config.Ref.Key]
if !ok {
return llmSvcCfg, fmt.Errorf("ConfigMap %s/%s doesn't have key %q in data",
cm.GetNamespace(),
cm.GetName(),
llmSvcCfg.Spec.Router.Scheduler.Config.Ref.Key,
)
}
llmSvcCfg.Spec.Router.Scheduler.Config.Inline = &runtime.RawExtension{Raw: []byte(cfg)}
// Clear the Ref since we've resolved it to Inline - the validator rejects having both set.
// Skip clearing if the caller needs to check which ConfigMap was referenced.
if !options.skipClearSchedulerConfigRef {
llmSvcCfg.Spec.Router.Scheduler.Config.Ref = nil
}
}
// Skip validation when we're only using the result for matching (not for reconciliation).
// When skipClearSchedulerConfigRef is true, both Inline and Ref may be set, which would fail validation.
if !options.skipClearSchedulerConfigRef {
err = r.Validator(ctx, &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: llmSvc.Name,
Namespace: llmSvc.GetNamespace(),
},
Spec: llmSvcCfg.Spec,
})
if err != nil {
return llmSvcCfg, err
}
}
return llmSvcCfg, nil
}
func isUsingTokenizerSidecar(spec v1alpha2.LLMInferenceServiceSpec) bool {
if spec.Router == nil || spec.Router.Scheduler == nil || spec.Router.Scheduler.Template == nil {
return false
}
return utils.GetContainerWithName(spec.Router.Scheduler.Template, tokenizerContainerName) != nil
}
// ToParentRefs converts a slice of UntypedObjectReference (gateway refs) to a slice
// of gwapiv1.ParentReference suitable for setting on an HTTPRoute's CommonRouteSpec.
// When a ref includes SectionName, the generated ParentReference targets that
// specific Gateway listener; otherwise the route attaches to all listeners.
func ToParentRefs(gatewayRefs []v1alpha2.GatewayObjectReference) []gwapiv1.ParentReference {
parentRefs := make([]gwapiv1.ParentReference, 0, len(gatewayRefs))
for _, ref := range gatewayRefs {
parentRef := gwapiv1.ParentReference{
Name: ref.Name,
Namespace: &ref.Namespace,
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
SectionName: ref.SectionName,
}
parentRefs = append(parentRefs, parentRef)
}
return parentRefs
}
// templateGlobalConfig exposes only the non-sensitive fields of Config to templates.
// StorageConfig and CredentialConfig are intentionally excluded to prevent template
// injection from accessing internal controller configuration.
type templateGlobalConfig struct {
SystemNamespace string
IngressGatewayName string
IngressGatewayNamespace string
EnableTLS bool
}
// ReplaceVariables processes the configuration as a Go template to substitute
// variables with values from the LLM service and global configuration.
func ReplaceVariables(llmSvc *v1alpha2.LLMInferenceService, llmSvcCfg *v1alpha2.LLMInferenceServiceConfig, reconcilerConfig *Config) (*v1alpha2.LLMInferenceServiceConfig, error) {
templateBytes, err := json.Marshal(llmSvcCfg)
if err != nil {
return nil, fmt.Errorf("failed to marshal config for template processing: %w", err)
}
buf := bytes.NewBuffer(nil)
var gc templateGlobalConfig
if reconcilerConfig != nil {
gc = templateGlobalConfig{
SystemNamespace: reconcilerConfig.SystemNamespace,
IngressGatewayName: reconcilerConfig.IngressGatewayName,
IngressGatewayNamespace: reconcilerConfig.IngressGatewayNamespace,
EnableTLS: reconcilerConfig.EnableTLS,
}
}
config := struct {
*v1alpha2.LLMInferenceService
GlobalConfig templateGlobalConfig
}{
LLMInferenceService: llmSvc,
GlobalConfig: gc,
}
t, err := template.New("config").
Funcs(map[string]any{
"ChildName": kmeta.ChildName,
}).
Option("missingkey=error").
Parse(string(templateBytes))
if err != nil {
return nil, fmt.Errorf("failed to parse template config: %w", err)
}
if err := t.Execute(buf, config); err != nil {
return nil, fmt.Errorf("failed to merge config: %w", err)
}
out := &v1alpha2.LLMInferenceServiceConfig{}
if err := json.Unmarshal(buf.Bytes(), out); err != nil {
return nil, fmt.Errorf("failed to unmarshal config from template: %w", err)
}
return out, nil
}
// getConfig retrieves kserveapis.LLMInferenceServiceConfig with the given name from either the kserveapis.LLMInferenceService
// namespace or from the SystemNamespace (e.g. 'kserve'), prioritizing the former.
// This allows for both global default configs and service-specific overrides.
func (r *LLMISVCReconciler) getConfig(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, name string) (*v1alpha2.LLMInferenceServiceConfig, error) {
cfg := &v1alpha2.LLMInferenceServiceConfig{}
if err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: llmSvc.Namespace}, cfg); err != nil {
if apierrors.IsNotFound(err) {
cfg = &v1alpha2.LLMInferenceServiceConfig{}
if err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: constants.KServeNamespace}, cfg); err != nil {
// TODO: add available LLMInferenceServiceConfig in system namespace and llmSvc.Namespace namespace if not found
return nil, fmt.Errorf("failed to get LLMInferenceServiceConfig %q from namespaces [%q, %q]: %w", name, llmSvc.Namespace, constants.KServeNamespace, err)
}
return cfg, nil
}
return nil, fmt.Errorf("failed to get LLMInferenceServiceConfig %s/%s: %w", llmSvc.Namespace, name, err)
}
return cfg, nil
}
func MergeSpecs(ctx context.Context, cfgs ...v1alpha2.LLMInferenceServiceSpec) (v1alpha2.LLMInferenceServiceSpec, error) {
if len(cfgs) == 0 {
return v1alpha2.LLMInferenceServiceSpec{}, nil
}
out := cfgs[0]
for i := 1; i < len(cfgs); i++ {
cfg := cfgs[i]
var err error
out, err = mergeSpecs(ctx, out, cfg)
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("failed to merge specs: %w", err)
}
}
return out, nil
}
// mergeSpecs performs a strategic merge by creating a clean patch from the override
// object and applying it to the base object.
func mergeSpecs(ctx context.Context, base, override v1alpha2.LLMInferenceServiceSpec) (v1alpha2.LLMInferenceServiceSpec, error) {
baseJSON, err := json.Marshal(base)
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not marshal base spec: %w", err)
}
// To create a patch containing only the fields specified in the override,
// we create a patch between a zero-valued ("empty") object and the override object.
// This prevents zero-valued fields in the override struct (e.g., an empty string for an
// unspecified image) from incorrectly wiping out values from the base.
zero := v1alpha2.LLMInferenceServiceSpec{}
zeroJSON, err := json.Marshal(zero)
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not marshal zero spec: %w", err)
}
// This ensures that only explicitly set fields in the override are applied, preventing
// zero-valued fields from overwriting meaningful base values.
override.SetDefaults(ctx)
overrideJSON, err := json.Marshal(override)
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not marshal override spec: %w", err)
}
logger := log.FromContext(ctx)
// Create the patch. It will only contain the non-default fields from the override.
patch, err := strategicpatch.CreateTwoWayMergePatch(zeroJSON, overrideJSON, v1alpha2.LLMInferenceServiceSpec{})
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not create merge patch from override: %w", err)
}
logger.V(2).Info("merging specs (patch)", "patch", string(patch), "base", string(baseJSON), "override", string(overrideJSON), "zero", string(zeroJSON))
// Apply this "clean" patch to the base JSON. The strategic merge logic will correctly
// merge lists and objects based on their Kubernetes patch strategy annotations.
mergedJSON, err := strategicpatch.StrategicMergePatch(baseJSON, patch, v1alpha2.LLMInferenceServiceSpec{})
if err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not apply merge patch: %w", err)
}
// Unmarshal the merged JSON back into a Go struct.
var finalSpec v1alpha2.LLMInferenceServiceSpec
if err := json.Unmarshal(mergedJSON, &finalSpec); err != nil {
return v1alpha2.LLMInferenceServiceSpec{}, fmt.Errorf("could not unmarshal merged spec: %w", err)
}
return finalSpec, nil
}
func isDefaultBackendRef(llmSvc *v1alpha2.LLMInferenceService, ref gwapiv1.BackendRef) bool {
defaultInfPoolName := (&v1alpha2.SchedulerSpec{}).InferencePoolName(llmSvc)
// Check Kind and Name only - Group can be either v1 or v1alpha2
return ptr.Deref[gwapiv1.Kind](ref.Kind, "") == "InferencePool" &&
string(ref.Name) == defaultInfPoolName
}
const (
StaticWellKnownConfigResolverPrefix = "serving.kserve.io/"
)
// WellKnownConfigResolver snapshots well-known config name mappings into Status.Annotations
// and resolves pinned names during reconciliation. This ensures that future prefix changes
// don't affect existing services.
type WellKnownConfigResolver struct{}
// Attach pins the current well-known config name mappings into the LLMInferenceService's
// Status.Annotations at first reconciliation. Already-pinned entries are preserved.
// NOTE: This mutates llmSvc.Status.Annotations in-place; the caller is responsible for
// persisting the status update.
func (w *WellKnownConfigResolver) Attach(llmSvc *v1alpha2.LLMInferenceService) {
if !useVersionedConfig {
return
}
for _, t := range WellKnownDefaultConfigs.UnsortedList() {
suffix, _ := strings.CutPrefix(t, configPrefix)
key := StaticWellKnownConfigResolverPrefix + suffix
if v, ok := llmSvc.Status.Annotations[key]; ok && v != "" {
continue
}
if llmSvc.Status.Annotations == nil {
llmSvc.Status.Annotations = map[string]string{}
}
llmSvc.Status.Annotations[key] = t
}
}
// Resolve returns the pinned config name from Status.Annotations if versioned config
// resolution is enabled, otherwise returns the name as-is.
func (w *WellKnownConfigResolver) Resolve(llmSvc *v1alpha2.LLMInferenceService, name string) string {
if !useVersionedConfig || llmSvc.Status.Annotations == nil {
return name
}
suffix, _ := strings.CutPrefix(name, configPrefix)
key := StaticWellKnownConfigResolverPrefix + suffix
if v, ok := llmSvc.Status.Annotations[key]; ok {
return v
}
return name
}