Skip to content

Commit 5bcbbb2

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 ccc7d07 commit 5bcbbb2

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
@@ -362,6 +362,79 @@ def build_command(config_path, output, params):
362362
click.echo(f"Compiled Harbor task: {compiled}")
363363

364364

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