@@ -378,6 +378,132 @@ def _busy_body(requested_tier: str) -> bytes:
378378 ).encode ("utf-8" )
379379
380380
381+ def _feasibility_response (table : RoutingTable , requested : str | None ) -> GatewayResponse | None :
382+ """404 ``role_infeasible`` iff ``requested``'s owning backend is declared
383+ hardware-infeasible by this deployment's per-machine profile (task t6);
384+ ``None`` when there is no such gate to apply. Shared by both the
385+ tier-alias and plain-id resolution paths in :func:`handle_post` so the
386+ feasibility gate — which outranks pressure-shedding and is never bypassed
387+ by ``X-Lobes-Override`` — is checked identically in both.
388+ """
389+ infeasible_name = infeasible_owner (table , requested )
390+ if infeasible_name is None :
391+ return None
392+ return GatewayResponse (
393+ status = 404 ,
394+ headers = [("Content-Type" , _CONTENT_TYPE_JSON )],
395+ body = _role_infeasible_body (requested , infeasible_name ),
396+ )
397+
398+
399+ def _resolve_tier (
400+ table : RoutingTable ,
401+ requested : str | None ,
402+ pressure : dict [str , float ],
403+ override : bool ,
404+ ) -> tuple [GatewayResponse | None , str | None , list [tuple [str , str ]]]:
405+ """The tier-alias branch of :func:`handle_post`: hardware feasibility gate,
406+ then pressure-aware busy shedding (#85), then the resolved served name.
407+
408+ Returns ``(early_response, served, tier_headers)``. When ``early_response``
409+ is not ``None`` the caller must return it immediately without dialing any
410+ backend; ``served``/``tier_headers`` are only meaningful otherwise.
411+ """
412+ early = _feasibility_response (table , requested )
413+ if early is not None :
414+ return early , None , []
415+ decision = resolve_tier_request (requested , pressure , override , table )
416+ if decision ["busy" ]:
417+ busy_response = GatewayResponse (
418+ status = 429 ,
419+ headers = [
420+ ("Retry-After" , str (BUSY_RETRY_AFTER_SECONDS )),
421+ ("X-Lobes-Tier-Reason" , "busy" ),
422+ ("Content-Type" , _CONTENT_TYPE_JSON ),
423+ ],
424+ body = _busy_body (decision ["requested_tier" ]),
425+ )
426+ return busy_response , None , []
427+ served = decision ["served_name" ]
428+ tier_headers = [
429+ ("X-Lobes-Tier" , decision ["served_tier" ]),
430+ ("X-Lobes-Tier-Reason" , decision ["reason" ]),
431+ ]
432+ return None , served , tier_headers
433+
434+
435+ def _resolve_plain_model (
436+ table : RoutingTable , requested : str | None
437+ ) -> tuple [GatewayResponse | None , str | None ]:
438+ """The non-tier branch of :func:`handle_post`: unknown-id 404 (h23), then
439+ the hardware feasibility gate, then the resolved served name.
440+
441+ Returns ``(early_response, served)``; when ``early_response`` is not
442+ ``None`` the caller must return it immediately.
443+ """
444+ if is_unknown_model (table , requested ):
445+ response = GatewayResponse (
446+ status = 404 ,
447+ headers = [("Content-Type" , _CONTENT_TYPE_JSON )],
448+ body = _model_not_found_body (requested ),
449+ )
450+ return response , None
451+ early = _feasibility_response (table , requested )
452+ if early is not None :
453+ return early , None
454+ return None , resolve_model (table , requested )
455+
456+
457+ def _try_backends (
458+ ordered : list [Backend ],
459+ cfg : ServerConfig ,
460+ path : str ,
461+ fwd_body : bytes ,
462+ fwd_headers : list [tuple [str , str ]],
463+ open_upstream : OpenUpstream ,
464+ streaming : bool ,
465+ tier_headers : list [tuple [str , str ]],
466+ ) -> tuple [GatewayResponse | None , list [str ]]:
467+ """Attempt each backend in ``ordered`` (in practice exactly one — no
468+ cross-backend failover, #91) and relay the first 2xx/4xx verbatim.
469+
470+ Returns ``(response, attempts)``: ``response`` is ``None`` iff every
471+ backend refused / timed out / 5xx'd, in which case the caller maps
472+ ``attempts`` to the retryable 503.
473+ """
474+ attempts : list [str ] = []
475+ for backend in ordered :
476+ try :
477+ up = open_upstream (
478+ backend ,
479+ path ,
480+ fwd_body ,
481+ fwd_headers ,
482+ connect_timeout = cfg .connect_timeout ,
483+ read_timeout = cfg .read_timeout ,
484+ )
485+ except UpstreamError as exc :
486+ attempts .append (str (exc ))
487+ continue
488+ if up .status >= 500 :
489+ attempts .append (f"{ backend .name } : HTTP { up .status } " )
490+ up .close ()
491+ continue
492+ # 2xx or 4xx → commit to the owner and relay verbatim. A 4xx is a genuine
493+ # CLIENT error: the owner is the only backend that could serve this model.
494+ return (
495+ GatewayResponse (
496+ status = up .status ,
497+ headers = tier_headers + up .headers ,
498+ upstream = up ,
499+ streaming = streaming ,
500+ attempts = attempts ,
501+ ),
502+ attempts ,
503+ )
504+ return None , attempts
505+
506+
381507def handle_post (
382508 table : RoutingTable ,
383509 cfg : ServerConfig ,
@@ -448,29 +574,9 @@ def handle_post(
448574 # infeasible role (e.g. "cortex") is rejected outright, never silently
449575 # re-routed to a different, feasible gear via the tier system's normal
450576 # upward-fallback substitution.
451- infeasible_name = infeasible_owner (table , requested )
452- if infeasible_name is not None :
453- return GatewayResponse (
454- status = 404 ,
455- headers = [("Content-Type" , _CONTENT_TYPE_JSON )],
456- body = _role_infeasible_body (requested , infeasible_name ),
457- )
458- decision = resolve_tier_request (requested , pressure , override , table )
459- if decision ["busy" ]:
460- return GatewayResponse (
461- status = 429 ,
462- headers = [
463- ("Retry-After" , str (BUSY_RETRY_AFTER_SECONDS )),
464- ("X-Lobes-Tier-Reason" , "busy" ),
465- ("Content-Type" , _CONTENT_TYPE_JSON ),
466- ],
467- body = _busy_body (decision ["requested_tier" ]),
468- )
469- served = decision ["served_name" ]
470- tier_headers = [
471- ("X-Lobes-Tier" , decision ["served_tier" ]),
472- ("X-Lobes-Tier-Reason" , decision ["reason" ]),
473- ]
577+ early , served , tier_headers = _resolve_tier (table , requested , pressure , override )
578+ if early is not None :
579+ return early
474580 else :
475581 # h23 converse: an UNKNOWN non-empty id (never an alias, never a wired
476582 # backend's served name) must NOT be silently served under the default
@@ -480,32 +586,17 @@ def handle_post(
480586 # /v1/models list — so a wired-but-dead backend (dropped from /v1/models but
481587 # still in the table) is KNOWN and routes on to the retryable 503 below, not
482588 # a 404 (that distinction is what keeps issue #91 fixed). An UNSPECIFIED
483- # (missing/blank) model is not unknown — it routes to default_model.
484- if is_unknown_model (table , requested ):
485- return GatewayResponse (
486- status = 404 ,
487- headers = [("Content-Type" , _CONTENT_TYPE_JSON )],
488- body = _model_not_found_body (requested ),
489- )
490- # Hardware feasibility gate (task t6), mirroring the tier branch above:
491- # AFTER the unknown-model check (a genuinely never-advertised id still
492- # gets model_not_found, not this), but BEFORE resolving/dialing a
493- # backend — so a known id/alias pinned to an infeasible backend (a
494- # concrete served model id, a custom operator alias, or a tier alias
495- # resolved via the static table when no PressureCache is wired) is
496- # rejected outright, never silently served by a different gear.
497- infeasible_name = infeasible_owner (table , requested )
498- if infeasible_name is not None :
499- return GatewayResponse (
500- status = 404 ,
501- headers = [("Content-Type" , _CONTENT_TYPE_JSON )],
502- body = _role_infeasible_body (requested , infeasible_name ),
503- )
504- served = resolve_model (table , requested )
589+ # (missing/blank) model is not unknown — it routes to default_model. The
590+ # hardware feasibility gate (task t6) mirrors the tier branch above: it
591+ # runs AFTER the unknown-model check (a genuinely never-advertised id
592+ # still gets model_not_found, not role_infeasible) but BEFORE
593+ # resolving/dialing a backend.
594+ early , served = _resolve_plain_model (table , requested )
595+ if early is not None :
596+ return early
505597 streaming = is_streaming (body )
506598 fwd_body = rewrite_model (body , served )
507599 fwd_headers = filter_headers (req_headers )
508- attempts : list [str ] = []
509600
510601 ordered = order_backends (table , served )
511602 if not ordered :
@@ -518,36 +609,22 @@ def handle_post(
518609 return GatewayResponse (
519610 status = 502 ,
520611 headers = tier_headers + [("Content-Type" , _CONTENT_TYPE_JSON )],
521- body = _error_body ("no backend owns the requested model" , attempts ),
522- attempts = attempts ,
612+ body = _error_body ("no backend owns the requested model" , [] ),
613+ attempts = [] ,
523614 )
524615
525- for backend in ordered : # exactly one backend — no failover chain (#91)
526- try :
527- up = open_upstream (
528- backend ,
529- path ,
530- fwd_body ,
531- fwd_headers ,
532- connect_timeout = cfg .connect_timeout ,
533- read_timeout = cfg .read_timeout ,
534- )
535- except UpstreamError as exc :
536- attempts .append (str (exc ))
537- continue
538- if up .status >= 500 :
539- attempts .append (f"{ backend .name } : HTTP { up .status } " )
540- up .close ()
541- continue
542- # 2xx or 4xx → commit to the owner and relay verbatim. A 4xx is a genuine
543- # CLIENT error: the owner is the only backend that could serve this model.
544- return GatewayResponse (
545- status = up .status ,
546- headers = tier_headers + up .headers ,
547- upstream = up ,
548- streaming = streaming ,
549- attempts = attempts ,
550- )
616+ response , attempts = _try_backends (
617+ ordered ,
618+ cfg ,
619+ path ,
620+ fwd_body ,
621+ fwd_headers ,
622+ open_upstream ,
623+ streaming ,
624+ tier_headers ,
625+ )
626+ if response is not None :
627+ return response
551628
552629 # The single owner refused / timed out / 5xx'd. With no failover (#91) it is
553630 # the ONLY backend that could serve `served`, so this is a TRANSIENT owner-down
0 commit comments