Skip to content

Commit d8f66e2

Browse files
feat: preflight configured models before launching an optimizer trial
A model that is configured but not deployed upstream is invisible until the run is over. The gateway forwards the request, the upstream 404s, the agent makes no progress, and every case is scored 0.0 and labelled `task_failure` — a run that looks honest and says the candidate failed. That is how swe-atlas-qna produced 469 zero-score cases across two runs (#51): `gpt-5.4-mini-2026-03-17` is not provisioned on the configured Azure resource, so 322/322 evaluation-scope and 79/79 finalization-scope requests returned 404 DeploymentNotFound while the producer scope ran clean. `vero harbor run` now sends one 16-token request per distinct allow-listed model before compiling the task, and aborts if any comes back 404. Only a definitive 404 is fatal. A timeout, a 429 or a 503 is inconclusive and is reported without blocking, so a transient upstream blip cannot stop a run that would have succeeded. Names are probed with the provider prefix stripped, which is the form the agents actually send. Verified against the live endpoint: gaia (which points at the undeployed model) now exits 1 in about two seconds with the DeploymentNotFound body quoted, instead of burning ~30 minutes and ~$2; a gpt-5.3-codex producer with a gpt-4o evaluation scope passes. Refs #51. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ec708e9 commit d8f66e2

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

vero/src/vero/harbor/cli.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,79 @@ def build_command(config_path, output, params):
363363
click.echo(f"Compiled Harbor task: {compiled}")
364364

365365

366+
def _probe_model(base_url: str, api_key: str, model: str) -> tuple[int | None, str]:
367+
"""Ask the upstream for one token from `model`. Returns (status, body)."""
368+
request = urllib.request.Request(
369+
f"{base_url.rstrip('/')}/responses",
370+
data=json.dumps(
371+
{"model": model, "input": "ok", "max_output_tokens": 16}
372+
).encode(),
373+
headers={
374+
"Content-Type": "application/json",
375+
"Authorization": f"Bearer {api_key}",
376+
# Azure OpenAI authenticates on api-key; OpenAI ignores it.
377+
"api-key": api_key,
378+
},
379+
)
380+
try:
381+
with urllib.request.urlopen(request, timeout=30) as response:
382+
return response.status, ""
383+
except urllib.error.HTTPError as error:
384+
return error.code, error.read().decode("utf-8", "replace")[:400]
385+
except Exception as error: # network/DNS/timeout: inconclusive, not fatal
386+
return None, str(error)
387+
388+
389+
def _preflight_models(config) -> None:
390+
"""Refuse to launch when a configured model is not deployed upstream.
391+
392+
A missing deployment is invisible until the run is over: the gateway
393+
forwards the request, the upstream 404s, the agent makes no progress, and
394+
every case is scored 0.0 as an honest-looking task failure. That costs a
395+
full optimizer trial to discover. This costs one token per model.
396+
397+
Only a definitive 404 blocks. Anything else (a timeout, a rate limit, a
398+
503) is inconclusive and must not stop a run that would have succeeded.
399+
"""
400+
gateway = getattr(config, "inference_gateway", None)
401+
if gateway is None:
402+
return
403+
api_key = os.environ.get(gateway.upstream_api_key_env)
404+
if not api_key:
405+
return
406+
base_url = gateway.default_upstream_base_url
407+
if gateway.upstream_base_url_env:
408+
base_url = os.environ.get(gateway.upstream_base_url_env) or base_url
409+
410+
scopes: dict[str, str] = {}
411+
for scope_name in ("producer", "evaluation", "finalization"):
412+
scope = getattr(gateway, scope_name, None)
413+
if scope is None:
414+
continue
415+
for name in scope.allowed_models:
416+
# Agents strip the provider prefix before calling the gateway.
417+
scopes.setdefault(name.removeprefix("openai/"), scope_name)
418+
419+
missing: list[str] = []
420+
for name, scope_name in scopes.items():
421+
status, body = _probe_model(base_url, api_key, name)
422+
if status == 404:
423+
missing.append(f"{name} ({scope_name} scope): {body.strip()}")
424+
elif status is not None and status >= 400:
425+
click.echo(
426+
f"Preflight: {name} returned HTTP {status}; continuing "
427+
f"(only a 404 is treated as fatal)"
428+
)
429+
if missing:
430+
raise click.ClickException(
431+
"these models are not deployed on "
432+
f"{base_url} and every request to them would fail:\n - "
433+
+ "\n - ".join(missing)
434+
+ "\nNote a model can appear in GET /models (the catalogue) and "
435+
"still have no deployment."
436+
)
437+
438+
366439
@harbor.command(
367440
"run",
368441
context_settings={"ignore_unknown_options": True},
@@ -409,6 +482,7 @@ def run_command(config_path, agent, model, environment, params, env_file, extra)
409482
if model is not None:
410483
resolved.setdefault("optimizer_model", model)
411484
config = load_harbor_build_config(config_path, params=resolved)
485+
_preflight_models(config)
412486
with tempfile.TemporaryDirectory(prefix="vero-harbor-") as temporary:
413487
task = compile_harbor_task(
414488
config,

vero/tests/test_v05_cli.py

Lines changed: 74 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,76 @@ 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+
# The provider prefix is stripped before the gateway sees the name, so the
362+
# probe has to use the same form the agent will send.
363+
probed.clear()
364+
harbor_cli._preflight_models(_Build(_Gateway(["openai/gpt-4o"], ["gpt-4o"])))
365+
assert probed == ["gpt-4o"]
366+
367+
368+
def test_preflight_does_not_block_on_inconclusive_upstream_failures(monkeypatch):
369+
from vero.harbor import cli as harbor_cli
370+
371+
monkeypatch.setenv("OPENAI_API_KEY", "key")
372+
monkeypatch.setenv("OPENAI_BASE_URL", "https://example.invalid/openai/v1")
373+
374+
for status, body in ((429, "rate limited"), (503, "unavailable"), (None, "timeout")):
375+
monkeypatch.setattr(
376+
harbor_cli, "_probe_model", lambda b, k, m, s=status, y=body: (s, y)
377+
)
378+
# Must not raise: a transient upstream blip cannot be allowed to stop a
379+
# run that would have succeeded.
380+
harbor_cli._preflight_models(_Build(_Gateway(["a"], ["b"])))
381+
382+
# No credentials and no gateway are both no-ops rather than errors.
383+
monkeypatch.delenv("OPENAI_API_KEY")
384+
harbor_cli._preflight_models(_Build(_Gateway(["a"], ["b"])))
385+
harbor_cli._preflight_models(_Build(None))

0 commit comments

Comments
 (0)