@@ -34,6 +34,10 @@ const (
3434
3535 // defaultHeaderName is the request header read when parameters.HeaderName is empty.
3636 defaultHeaderName = "EPP-Phase"
37+
38+ // defaultProfileName is the scheduling profile run when parameters.DefaultProfile is
39+ // empty.
40+ defaultProfileName = "decode"
3741)
3842
3943// compile-time type assertion
@@ -44,6 +48,11 @@ type parameters struct {
4448 // HeaderName is the request header whose value names the scheduling profile to run.
4549 // Defaults to defaultHeaderName when empty.
4650 HeaderName string `json:"headerName"`
51+ // DefaultProfile is the scheduling profile to run when the header is missing or
52+ // blank. Defaults to defaultProfileName when empty. Useful for requests that never
53+ // carry the header at all - pass-through calls (e.g. /models) or a deployment that
54+ // doesn't disaggregate.
55+ DefaultProfile string `json:"defaultProfile"`
4756}
4857
4958// Factory defines the factory function for HeaderPhaseProfileHandler.
@@ -55,24 +64,35 @@ func Factory(name string, rawParameters *json.Decoder, _ fwkplugin.Handle) (fwkp
5564 }
5665 }
5766
58- return NewHeaderPhaseProfileHandler (params .HeaderName ).WithName (name ), nil
67+ return NewHeaderPhaseProfileHandler (params .HeaderName , params . DefaultProfile ).WithName (name ), nil
5968}
6069
6170// NewHeaderPhaseProfileHandler initializes a new HeaderPhaseProfileHandler and returns
62- // its pointer. headerName is lowercased and trimmed, falling back to defaultHeaderName
63- // when that leaves it empty: the EPP's request handler lowercases every incoming header
64- // name at ingestion (pkg/epp/handlers/request.go), so the configured name must be
65- // normalized the same way to match, and an empty header key would never match any
66- // request.
67- func NewHeaderPhaseProfileHandler (headerName string ) * HeaderPhaseProfileHandler {
71+ // its pointer.
72+ //
73+ // headerName is lowercased and trimmed, falling back to defaultHeaderName when that
74+ // leaves it empty: the EPP's request handler lowercases every incoming header name at
75+ // ingestion (pkg/epp/handlers/request.go), so the configured name must be normalized the
76+ // same way to match, and an empty header key would never match any request.
77+ //
78+ // defaultProfile is trimmed, falling back to defaultProfileName when that leaves it
79+ // empty. Unlike headerName, it is not case-normalized: it names a schedulingProfiles
80+ // entry, which (like the header value itself) is matched case-sensitively.
81+ func NewHeaderPhaseProfileHandler (headerName , defaultProfile string ) * HeaderPhaseProfileHandler {
6882 headerName = strings .ToLower (strings .TrimSpace (headerName ))
6983 if headerName == "" {
7084 headerName = strings .ToLower (defaultHeaderName )
7185 }
7286
87+ defaultProfile = strings .TrimSpace (defaultProfile )
88+ if defaultProfile == "" {
89+ defaultProfile = defaultProfileName
90+ }
91+
7392 return & HeaderPhaseProfileHandler {
74- typedName : fwkplugin.TypedName {Type : HeaderPhaseProfileHandlerType , Name : HeaderPhaseProfileHandlerType },
75- headerName : headerName ,
93+ typedName : fwkplugin.TypedName {Type : HeaderPhaseProfileHandlerType , Name : HeaderPhaseProfileHandlerType },
94+ headerName : headerName ,
95+ defaultProfile : defaultProfile ,
7696 }
7797}
7898
@@ -81,9 +101,17 @@ func NewHeaderPhaseProfileHandler(headerName string) *HeaderPhaseProfileHandler
81101// phases of a disaggregated pipeline (e.g. encode, prefill, decode) whose caller already
82102// knows, out of band, which phase each request is for - unlike the disagg profile
83103// handler, which decides which profiles to run via decider plugins.
104+ //
105+ // Two fallbacks keep single-stage and header-less traffic working without a different
106+ // profile handler: with exactly one configured profile there is nothing to disaggregate,
107+ // so that profile always runs regardless of the header (or its absence); with more than
108+ // one configured profile, a request whose header is missing or blank runs defaultProfile
109+ // instead of failing. A header naming a profile that isn't configured is still an error -
110+ // only the header's absence triggers the default, not an unrecognized value.
84111type HeaderPhaseProfileHandler struct {
85- typedName fwkplugin.TypedName
86- headerName string
112+ typedName fwkplugin.TypedName
113+ headerName string
114+ defaultProfile string
87115}
88116
89117// TypedName returns the type and name tuple of this plugin instance.
@@ -116,44 +144,55 @@ func (h *HeaderPhaseProfileHandler) noMatchError(phase string) error {
116144 return fmt .Errorf ("header-phase profile handler: no scheduling profile configured for %q header value %q" , h .headerName , phase )
117145}
118146
119- // Pick selects the single SchedulingProfile named by the request's phase header. It
120- // returns an empty map once that profile has run, or when the header is missing or
121- // names a profile that isn't configured. In the latter case the scheduler's run loop
122- // (pkg/epp/scheduling.Scheduler.Schedule) stops without ever calling ProcessResults, so
123- // the specific reason is logged here rather than returned from ProcessResults, where it
124- // would be unreachable. The client never sees that reason: it only gets the scheduler's
125- // generic "failed to run any scheduler profile" error, which
126- // pkg/epp/requestcontrol/director.go maps to a 429 ResourceExhausted response -
127- // misleading, since a malformed or missing header is a client error, not a capacity
128- // problem. Surfacing the real reason to the client needs a scheduler/ProfileHandler
129- // contract change and is out of scope here; the log is a diagnostic aid for operators,
130- // not an equivalent substitute for what the caller receives.
147+ // Pick selects the single SchedulingProfile to run: the only configured profile when
148+ // there is just one, otherwise the one named by the request's phase header, falling
149+ // back to defaultProfile when the header is missing or blank. It returns an empty map
150+ // once that profile has run, or when no profile could be resolved. In the latter case
151+ // the scheduler's run loop (pkg/epp/scheduling.Scheduler.Schedule) stops without ever
152+ // calling ProcessResults, so the specific reason is logged here rather than returned
153+ // from ProcessResults, where it would be unreachable. The client never sees that
154+ // reason: it only gets the scheduler's generic "failed to run any scheduler profile"
155+ // error, which pkg/epp/requestcontrol/director.go maps to a 429 ResourceExhausted
156+ // response - misleading, since a malformed or missing header is a client error, not a
157+ // capacity problem. Surfacing the real reason to the client needs a
158+ // scheduler/ProfileHandler contract change and is out of scope here; the log is a
159+ // diagnostic aid for operators, not an equivalent substitute for what the caller
160+ // receives.
131161func (h * HeaderPhaseProfileHandler ) Pick (ctx context.Context , request * fwksched.InferenceRequest , profiles map [string ]fwksched.SchedulerProfile ,
132162 profileResults map [string ]* fwksched.ProfileRunResult ) map [string ]fwksched.SchedulerProfile {
133- // TODO(#2135): single profile per request; extend to loop over an ordered phase
134- // list parsed from the header for non-deferred multi-profile scheduling.
135163 if len (profileResults ) > 0 { // the selected profile has already run
136164 return map [string ]fwksched.SchedulerProfile {}
137165 }
138166
167+ // With exactly one configured profile there is nothing to disaggregate: always run
168+ // it, so a deployment scaled down to a single stage works without swapping profile
169+ // handlers or requiring every caller to send the header.
170+ if len (profiles ) == 1 {
171+ for name , profile := range profiles {
172+ return map [string ]fwksched.SchedulerProfile {name : profile }
173+ }
174+ }
175+
139176 phase := h .phaseHeader (request )
140- profile , ok := profiles [phase ]
177+ resolvedPhase := phase
178+ if resolvedPhase == "" {
179+ resolvedPhase = h .defaultProfile
180+ }
181+
182+ profile , ok := profiles [resolvedPhase ]
141183 if ! ok {
142184 log .FromContext (ctx ).Error (h .noMatchError (phase ), "no scheduling profile selected for request" )
143185 return map [string ]fwksched.SchedulerProfile {}
144186 }
145187
146- return map [string ]fwksched.SchedulerProfile {phase : profile }
188+ return map [string ]fwksched.SchedulerProfile {resolvedPhase : profile }
147189}
148190
149191// ProcessResults handles the outcome of the single profile run selected by Pick.
150192// It specifies in the SchedulingResult the key of the primary profile that should be
151193// used to get the request's selected destination.
152194func (h * HeaderPhaseProfileHandler ) ProcessResults (_ context.Context , request * fwksched.InferenceRequest ,
153195 profileResults map [string ]* fwksched.ProfileRunResult ) (* fwksched.SchedulingResult , error ) {
154- // TODO(#2135): single profile per request; extend by re-parsing the header's
155- // ordered phase list to pick a primary among multiple results for non-deferred
156- // multi-profile scheduling.
157196 switch len (profileResults ) {
158197 case 0 :
159198 return nil , h .noMatchError (h .phaseHeader (request ))
0 commit comments