Skip to content

Commit 2bbd44e

Browse files
authored
Merge pull request #59 from scaleapi/feat/preflight-model-deployments
feat: preflight configured models before launching an optimizer trial
2 parents ec708e9 + f02232a commit 2bbd44e

2 files changed

Lines changed: 283 additions & 0 deletions

File tree

vero/src/vero/harbor/cli.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
+ "\nNote 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,

vero/tests/test_v05_cli.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
from pathlib import Path
88

9+
import pytest
910
from click.testing import CliRunner
1011

1112
import vero
@@ -309,3 +310,150 @@ def test_cli_rejects_options_that_do_not_apply(tmp_path: Path):
309310
assert "are only valid with --produce" in producer_timeout.output
310311
assert wandb.exit_code == 2
311312
assert "require --wandb-project" in wandb.output
313+
314+
315+
class _Gateway:
316+
"""Minimal stand-in for InferenceGatewaySpec's preflight-relevant surface."""
317+
318+
upstream_api_key_env = "OPENAI_API_KEY"
319+
upstream_base_url_env = "OPENAI_BASE_URL"
320+
default_upstream_base_url = "https://api.openai.com/v1"
321+
finalization = None
322+
323+
def __init__(self, producer, evaluation):
324+
self.producer = type("S", (), {"allowed_models": producer})()
325+
self.evaluation = type("S", (), {"allowed_models": evaluation})()
326+
327+
328+
class _Build:
329+
def __init__(self, gateway):
330+
self.inference_gateway = gateway
331+
332+
333+
def test_preflight_blocks_only_on_a_definitively_missing_deployment(monkeypatch):
334+
"""A missing deployment is otherwise invisible until a whole trial has burned.
335+
336+
The upstream 404s, the agent makes no progress, and every case is scored 0.0
337+
as an honest-looking task failure.
338+
"""
339+
import click
340+
341+
from vero.harbor import cli as harbor_cli
342+
343+
monkeypatch.setenv("OPENAI_API_KEY", "key")
344+
monkeypatch.setenv("OPENAI_BASE_URL", "https://example.invalid/openai/v1")
345+
probed: list[str] = []
346+
347+
def _probe(base_url, api_key, model):
348+
probed.append(model)
349+
if model == "dead-model":
350+
return 404, '{"error": {"code": "DeploymentNotFound"}}'
351+
return 200, ""
352+
353+
monkeypatch.setattr(harbor_cli, "_probe_model", _probe)
354+
355+
with pytest.raises(click.ClickException) as raised:
356+
harbor_cli._preflight_models(
357+
_Build(_Gateway(["gpt-5.3-codex"], ["dead-model"]))
358+
)
359+
assert "dead-model (evaluation scope)" in str(raised.value)
360+
assert "DeploymentNotFound" in str(raised.value)
361+
# A provider prefix routes on a proxy and is meaningless to a single
362+
# provider endpoint, so the configured spelling is tried first.
363+
probed.clear()
364+
harbor_cli._preflight_models(_Build(_Gateway(["openai/gpt-4o"], ["gpt-4o"])))
365+
assert probed == ["openai/gpt-4o", "gpt-4o"]
366+
367+
368+
def test_preflight_falls_back_to_the_bare_name_before_calling_a_model_missing(
369+
monkeypatch,
370+
):
371+
"""One spelling 404ing is not evidence the model is absent.
372+
373+
Azure serves `gpt-5.3-codex` and 404s `openai/gpt-5.3-codex`; a routing
374+
proxy does the reverse. Blocking on the first spelling would refuse a run
375+
that would have worked.
376+
"""
377+
from vero.harbor import cli as harbor_cli
378+
379+
monkeypatch.setenv("OPENAI_API_KEY", "key")
380+
monkeypatch.setenv("OPENAI_BASE_URL", "https://example.invalid/openai/v1")
381+
probed: list[str] = []
382+
383+
def _probe(base_url, api_key, model):
384+
probed.append(model)
385+
if "/" in model:
386+
return 404, '{"error": {"code": "DeploymentNotFound"}}'
387+
return 200, ""
388+
389+
monkeypatch.setattr(harbor_cli, "_probe_model", _probe)
390+
harbor_cli._preflight_models(_Build(_Gateway(["openai/gpt-5.3-codex"], [])))
391+
assert probed == ["openai/gpt-5.3-codex", "gpt-5.3-codex"]
392+
393+
394+
def test_probe_model_separates_a_missing_route_from_a_missing_model(monkeypatch):
395+
"""An upstream that serves only Chat Completions must not read as missing.
396+
397+
Both failures are HTTP 404 and they mean opposite things, so the body is
398+
the only discriminator. gaia's agent uses the Responses API and the rest
399+
use Chat Completions, so either route may legitimately be absent.
400+
"""
401+
from vero.harbor import cli as harbor_cli
402+
403+
seen: list[str] = []
404+
405+
def _route(base_url, api_key, model, route, input_key):
406+
seen.append(route)
407+
if route == "/responses":
408+
return 404, '{"detail": "Not Found"}' # the route, not the model
409+
return 200, ""
410+
411+
monkeypatch.setattr(harbor_cli, "_probe_route", _route)
412+
assert harbor_cli._probe_model("https://x/v1", "k", "m") == (200, "")
413+
assert seen == ["/responses", "/chat/completions"]
414+
415+
# A model-level 404 on the first route is conclusive: do not keep probing.
416+
seen.clear()
417+
monkeypatch.setattr(
418+
harbor_cli,
419+
"_probe_route",
420+
lambda b, k, m, route, i: (
421+
seen.append(route),
422+
(404, '{"error": {"code": "DeploymentNotFound"}}'),
423+
)[1],
424+
)
425+
status, _ = harbor_cli._probe_model("https://x/v1", "k", "m")
426+
assert status == 404
427+
assert seen == ["/responses"]
428+
429+
# Neither route served: inconclusive, so the run is allowed to proceed.
430+
seen.clear()
431+
monkeypatch.setattr(
432+
harbor_cli,
433+
"_probe_route",
434+
lambda b, k, m, route, i: (seen.append(route), (404, "<html>404</html>"))[1],
435+
)
436+
status, body = harbor_cli._probe_model("https://x/v1", "k", "m")
437+
assert status is None
438+
assert seen == ["/responses", "/chat/completions"]
439+
assert "not served by this upstream" in body
440+
441+
442+
def test_preflight_does_not_block_on_inconclusive_upstream_failures(monkeypatch):
443+
from vero.harbor import cli as harbor_cli
444+
445+
monkeypatch.setenv("OPENAI_API_KEY", "key")
446+
monkeypatch.setenv("OPENAI_BASE_URL", "https://example.invalid/openai/v1")
447+
448+
for status, body in ((429, "rate limited"), (503, "unavailable"), (None, "timeout")):
449+
monkeypatch.setattr(
450+
harbor_cli, "_probe_model", lambda b, k, m, s=status, y=body: (s, y)
451+
)
452+
# Must not raise: a transient upstream blip cannot be allowed to stop a
453+
# run that would have succeeded.
454+
harbor_cli._preflight_models(_Build(_Gateway(["a"], ["b"])))
455+
456+
# No credentials and no gateway are both no-ops rather than errors.
457+
monkeypatch.delenv("OPENAI_API_KEY")
458+
harbor_cli._preflight_models(_Build(_Gateway(["a"], ["b"])))
459+
harbor_cli._preflight_models(_Build(None))

0 commit comments

Comments
 (0)