@@ -363,6 +363,140 @@ def build_command(config_path, output, params):
363363 click .echo (f"Compiled Harbor task: { compiled } " )
364364
365365
366+ #: The two request shapes an OpenAI-compatible upstream may accept. Agents in
367+ #: this repo use both (gaia is on Responses, the rest are on Chat Completions),
368+ #: and an upstream is free to implement only one, so a 404 from the first is
369+ #: not evidence about the model until the second has also been tried.
370+ _PROBE_ROUTES : tuple [tuple [str , str ], ...] = (
371+ ("/responses" , "input" ),
372+ ("/chat/completions" , "messages" ),
373+ )
374+
375+
376+ def _model_is_missing (body : str ) -> bool :
377+ """True when a 404 body blames the model rather than the route.
378+
379+ A route-level 404 (the upstream does not implement this path) and a
380+ model-level 404 (the deployment does not exist) share a status code and
381+ mean opposite things, so the body is the only thing that separates them.
382+ Matched on the providers' own error codes, and on the Azure and OpenAI
383+ sentences, never on a bare "does not exist".
384+ """
385+ lowered = body .lower ()
386+ if "deploymentnotfound" in lowered or "model_not_found" in lowered :
387+ return True
388+ return "model" in lowered and "does not exist" in lowered
389+
390+
391+ def _probe_route (
392+ base_url : str , api_key : str , model : str , route : str , input_key : str
393+ ) -> tuple [int | None , str ]:
394+ """Ask one route for one token from `model`. Returns (status, body)."""
395+ payload : dict [str , object ] = {"model" : model }
396+ if input_key == "input" :
397+ payload ["input" ] = "ok"
398+ payload ["max_output_tokens" ] = 16
399+ else :
400+ payload ["messages" ] = [{"role" : "user" , "content" : "ok" }]
401+ payload ["max_tokens" ] = 16
402+ request = urllib .request .Request (
403+ f"{ base_url .rstrip ('/' )} { route } " ,
404+ data = json .dumps (payload ).encode (),
405+ headers = {
406+ "Content-Type" : "application/json" ,
407+ "Authorization" : f"Bearer { api_key } " ,
408+ # Azure OpenAI authenticates on api-key; OpenAI ignores it.
409+ "api-key" : api_key ,
410+ },
411+ )
412+ try :
413+ with urllib .request .urlopen (request , timeout = 30 ) as response :
414+ return response .status , ""
415+ except urllib .error .HTTPError as error :
416+ return error .code , error .read ().decode ("utf-8" , "replace" )[:400 ]
417+ except Exception as error : # network/DNS/timeout: inconclusive, not fatal
418+ return None , str (error )
419+
420+
421+ def _probe_model (base_url : str , api_key : str , model : str ) -> tuple [int | None , str ]:
422+ """Ask the upstream for one token from `model`. Returns (status, body).
423+
424+ Tries each route until one is conclusive. A 404 is only returned when the
425+ body names the model; a route-level 404 falls through to the next route,
426+ and if every route 404s on the route rather than the model the result is
427+ reported as inconclusive so the run proceeds.
428+ """
429+ last : tuple [int | None , str ] = (None , "no route was reachable" )
430+ for route , input_key in _PROBE_ROUTES :
431+ status , body = _probe_route (base_url , api_key , model , route , input_key )
432+ if status == 404 and not _model_is_missing (body ):
433+ # This upstream does not serve this route. Says nothing about the
434+ # model; keep the result only as a fallback and try the next one.
435+ last = (None , f"{ route } is not served by this upstream" )
436+ continue
437+ return status , body
438+ return last
439+
440+
441+ def _preflight_models (config ) -> None :
442+ """Refuse to launch when a configured model is not deployed upstream.
443+
444+ A missing deployment is invisible until the run is over: the gateway
445+ forwards the request, the upstream 404s, the agent makes no progress, and
446+ every case is scored 0.0 as an honest-looking task failure. That costs a
447+ full optimizer trial to discover. This costs one token per model.
448+
449+ Only a definitive 404 blocks. Anything else (a timeout, a rate limit, a
450+ 503) is inconclusive and must not stop a run that would have succeeded.
451+ """
452+ gateway = getattr (config , "inference_gateway" , None )
453+ if gateway is None :
454+ return
455+ api_key = os .environ .get (gateway .upstream_api_key_env )
456+ if not api_key :
457+ return
458+ base_url = gateway .default_upstream_base_url
459+ if gateway .upstream_base_url_env :
460+ base_url = os .environ .get (gateway .upstream_base_url_env ) or base_url
461+
462+ scopes : dict [str , str ] = {}
463+ for scope_name in ("producer" , "evaluation" , "finalization" ):
464+ scope = getattr (gateway , scope_name , None )
465+ if scope is None :
466+ continue
467+ for name in scope .allowed_models :
468+ scopes .setdefault (name , scope_name )
469+
470+ missing : list [str ] = []
471+ for name , scope_name in scopes .items ():
472+ # A provider prefix is meaningful to a routing proxy and meaningless to
473+ # a single-provider endpoint, so try the configured name first and only
474+ # fall back to the bare one. Reporting a model missing on the strength
475+ # of one spelling would block a run that would have worked.
476+ candidates = [name ]
477+ if "/" in name :
478+ candidates .append (name .split ("/" , 1 )[1 ])
479+ for candidate in candidates :
480+ status , body = _probe_model (base_url , api_key , candidate )
481+ if status != 404 :
482+ break
483+ if status == 404 :
484+ missing .append (f"{ name } ({ scope_name } scope): { body .strip ()} " )
485+ elif status is not None and status >= 400 :
486+ click .echo (
487+ f"Preflight: { name } returned HTTP { status } ; continuing "
488+ f"(only a 404 is treated as fatal)"
489+ )
490+ if missing :
491+ raise click .ClickException (
492+ "these models are not deployed on "
493+ f"{ base_url } and every request to them would fail:\n - "
494+ + "\n - " .join (missing )
495+ + "\n Note a model can appear in GET /models (the catalogue) and "
496+ "still have no deployment."
497+ )
498+
499+
366500@harbor .command (
367501 "run" ,
368502 context_settings = {"ignore_unknown_options" : True },
@@ -409,6 +543,7 @@ def run_command(config_path, agent, model, environment, params, env_file, extra)
409543 if model is not None :
410544 resolved .setdefault ("optimizer_model" , model )
411545 config = load_harbor_build_config (config_path , params = resolved )
546+ _preflight_models (config )
412547 with tempfile .TemporaryDirectory (prefix = "vero-harbor-" ) as temporary :
413548 task = compile_harbor_task (
414549 config ,
0 commit comments