Skip to content

Commit f02232a

Browse files
fix: do not read a missing route as a missing model in preflight
The probe only spoke the Responses API and treated any 404 as proof the deployment was absent. A route-level 404 (the upstream does not implement that path) and a model-level 404 (the deployment does not exist) share a status code and mean opposite things, so the preflight would hard-block a run against a Chat-Completions-only upstream that would have succeeded. That is now a live risk in this repo: officeqa, tau3, swe-atlas-qna and browsecomp-plus were ported to Chat Completions, leaving gaia as the only agent still on Responses. Try both routes, and only call a model missing when the 404 body names the model (`DeploymentNotFound`, `model_not_found`, or "model ... does not exist") rather than the route. If every route 404s on the route itself, the result is inconclusive and the run proceeds. Same fix for the model name: the provider prefix routes on a proxy and is meaningless to a single-provider endpoint, so probe the configured spelling first and fall back to the bare one before reporting anything missing. Previously the prefix was stripped unconditionally, which is wrong for `fireworks_ai/...` names. Verified against the live Azure endpoint: `gpt-4o` 200 on both routes; `openai/gpt-5.3-codex` 404s prefixed and passes preflight via the bare fallback; `fireworks_ai/gpt-oss-120b` is still correctly blocked; and a model 404 body is distinguished from a bare route 404. Refs #51. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d8f66e2 commit f02232a

2 files changed

Lines changed: 147 additions & 12 deletions

File tree

vero/src/vero/harbor/cli.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,45 @@ 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)."""
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
368402
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(),
403+
f"{base_url.rstrip('/')}{route}",
404+
data=json.dumps(payload).encode(),
373405
headers={
374406
"Content-Type": "application/json",
375407
"Authorization": f"Bearer {api_key}",
@@ -386,6 +418,26 @@ def _probe_model(base_url: str, api_key: str, model: str) -> tuple[int | None, s
386418
return None, str(error)
387419

388420

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+
389441
def _preflight_models(config) -> None:
390442
"""Refuse to launch when a configured model is not deployed upstream.
391443
@@ -413,12 +465,21 @@ def _preflight_models(config) -> None:
413465
if scope is None:
414466
continue
415467
for name in scope.allowed_models:
416-
# Agents strip the provider prefix before calling the gateway.
417-
scopes.setdefault(name.removeprefix("openai/"), scope_name)
468+
scopes.setdefault(name, scope_name)
418469

419470
missing: list[str] = []
420471
for name, scope_name in scopes.items():
421-
status, body = _probe_model(base_url, api_key, name)
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
422483
if status == 404:
423484
missing.append(f"{name} ({scope_name} scope): {body.strip()}")
424485
elif status is not None and status >= 400:

vero/tests/test_v05_cli.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,85 @@ def _probe(base_url, api_key, model):
358358
)
359359
assert "dead-model (evaluation scope)" in str(raised.value)
360360
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.
361+
# A provider prefix routes on a proxy and is meaningless to a single
362+
# provider endpoint, so the configured spelling is tried first.
363363
probed.clear()
364364
harbor_cli._preflight_models(_Build(_Gateway(["openai/gpt-4o"], ["gpt-4o"])))
365-
assert probed == ["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
366440

367441

368442
def test_preflight_does_not_block_on_inconclusive_upstream_failures(monkeypatch):

0 commit comments

Comments
 (0)