Skip to content

Commit ed55bc7

Browse files
varunursekarclaude
andcommitted
Merge pr2-add-vero into pr3-harness-bench to end the vero/src divergence
Both branches had accumulated real vero/src work the other lacked -- 12 commits here (wandb URL repair, optimizer-trial harbor flags, model preflight, transient infra classification) against 5 on pr2 (opencode step limit, litellm base-url alias, outer Modal app naming, terminal budget status, backend in the eval plan). The stacked-PR assumption that vero/ lives only on pr2 had quietly stopped holding, and the cost was concrete: benchmark runs launch from this worktree, so three fixes committed on pr2 were simply absent at run time. It cost a wasted mini-swe-agent attempt and a gaia run launched without the step limit it needed. Merging rather than rebasing: PR #57 is already merged into this branch, so replaying commits over it would rewrite merged history for no benefit, and a merge resolves the overlap once instead of per-commit. Three conflicts, all where both sides edited the same lines: - The run command gained both sets of appended flags. The derived Modal app name now defers to an explicit app_name from *either* source, since a build can declare one in optimizer_harbor_args just as a caller can pass one on the command line, and appending ours after the build's would have silently won on harbor's last-value-per-key rule. - Three config stubs needed both `optimizer_harbor_args` and `name`; they were written before the other side's field existed. - Three assertions expecting `_opencode_gateway_args` to return nothing are now wrong by design: the step limit is unconditional, so a bare model name and a gateway-less task both still get it. Dropped those and kept every other test from both sides. 453 passed, 5 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents 44a852e + 2697735 commit ed55bc7

11 files changed

Lines changed: 284 additions & 33 deletions

File tree

vero/src/vero/evals_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ def plan_command(context_path, as_json):
646646
{
647647
"evaluation": evaluation.get("name"),
648648
"partition": evaluation.get("partition"),
649+
"backend": evaluation.get("backend"),
649650
"cases": evaluation.get("cases"),
650651
"can_evaluate": evaluation.get("agent_can_evaluate"),
651652
"selection": evaluation.get("agent_selection"),
@@ -662,6 +663,7 @@ def plan_command(context_path, as_json):
662663
[
663664
"evaluation",
664665
"partition",
666+
"backend",
665667
"cases",
666668
"can_evaluate",
667669
"selection",

vero/src/vero/gateway/inference.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -781,8 +781,15 @@ async def log_request(
781781
try:
782782
await store.reserve(scope_name, attribution)
783783
except InferenceBudgetExceeded as error:
784-
await log_request(status=429, error="budget_exhausted")
785-
return _provider_error(429, str(error), "budget_exhausted")
784+
# 402, not 429. Budget exhaustion is terminal -- no amount of waiting
785+
# restores the quota -- but 429 means "slow down and try again", and
786+
# every layer above honours that: EvaluationLimits.retry_status_codes
787+
# defaults to [429, 503, 529], and target-agent SDKs retry it too.
788+
# officeqa run #2 reissued 3672 doomed requests after exhausting the
789+
# finalization scope, burning verifier wall-clock to no purpose.
790+
# 402 is in no retry list, so the caller fails fast with the reason.
791+
await log_request(status=402, error="budget_exhausted")
792+
return _provider_error(402, str(error), "budget_exhausted")
786793

787794
headers = {
788795
name: value

vero/src/vero/harbor/backend.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,8 +1255,9 @@ def _case_result(
12551255
exception_counts[key] = exception_counts.get(key, 0) + 1
12561256
signals.append(key)
12571257
# The gateway embeds a distinct "budget_exhausted" code in the error
1258-
# body; the in-container client collapses the type to a generic
1259-
# rate-limit, but the message survives, so classify on it too.
1258+
# body. Classify on the message rather than the status or exception
1259+
# type: the in-container client collapses the type to whatever its
1260+
# SDK maps the status to, but the message survives intact.
12601261
for detail_key in ("message", "detail", "exception_message"):
12611262
detail = info.get(detail_key)
12621263
if isinstance(detail, str) and detail:

vero/src/vero/harbor/cli.py

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,48 @@ def _load_env_file(path: Path) -> dict[str, str]:
224224
return values
225225

226226

227+
# opencode's own default is 100 agentic iterations, after which it forces a
228+
# text-only response and stops. That is well inside a real optimization run:
229+
# gaia's optimizer used all 100 and never reached `evals submit`. Set high
230+
# enough that the harness never truncates the search -- the case budget and
231+
# the gateway token cap are the intended limits.
232+
OPENCODE_STEP_LIMIT = 1000
233+
234+
# Harnesses that drive the model through litellm rather than a provider SDK.
235+
# litellm reads the base URL as <PROVIDER>_API_BASE; the SDKs read
236+
# <PROVIDER>_BASE_URL. vero sets the SDK names, so a litellm-based harness sees no
237+
# override and calls the provider's public endpoint instead of the gateway.
238+
_LITELLM_AGENTS = frozenset({"mini-swe-agent", "swe-agent"})
239+
240+
241+
def _litellm_base_url_args(agent: str, task: Path) -> list[str]:
242+
"""Give litellm-based harnesses the gateway URL under the name they read.
243+
244+
mini-swe-agent installs `litellm[proxy]` and resolves `anthropic/<model>`
245+
through it. Without ANTHROPIC_API_BASE it reaches api.anthropic.com holding
246+
only a scoped gateway token, and fails with
247+
``AuthenticationError: invalid x-api-key`` -- fails closed, so nothing leaks,
248+
but the harness cannot run at all. Set both provider aliases from the
249+
compiled producer scope so whichever provider the model names is covered.
250+
"""
251+
252+
if agent not in _LITELLM_AGENTS:
253+
return []
254+
path = task / "environment/gateway/launch.json"
255+
if not path.exists():
256+
return []
257+
try:
258+
base_url = json.loads(path.read_text(encoding="utf-8"))["producer_base_url"]
259+
except (OSError, json.JSONDecodeError, KeyError):
260+
return []
261+
# The Anthropic SDK re-appends /v1, litellm does not, so anthropic keeps the
262+
# full path here -- matching how the gateway is addressed for openai.
263+
arguments: list[str] = []
264+
for name in ("OPENAI_API_BASE", "ANTHROPIC_API_BASE"):
265+
arguments.extend(["--ae", f"{name}={base_url}"])
266+
return arguments
267+
268+
227269
def _opencode_gateway_args(agent: str, model: str | None, task: Path) -> list[str]:
228270
"""Route opencode's non-openai providers through the gateway.
229271
@@ -244,22 +286,64 @@ def _opencode_gateway_args(agent: str, model: str | None, task: Path) -> list[st
244286
and metered. The adapter deep-merges job kwargs last, so this wins.
245287
"""
246288

247-
if agent != "opencode" or not model or "/" not in model:
289+
if agent != "opencode" or not model:
248290
return []
291+
292+
# opencode caps agentic iterations at 100 by default and then "forces a
293+
# text-only response" (its own config schema's words for `steps`). A gaia
294+
# optimizer hit that cap after ~2h, and the forced final message reads like a
295+
# considered wrap-up, so the truncation is invisible unless you notice the
296+
# step count is exactly 100 -- it never reached `evals submit`. claude-code
297+
# takes harbor's --max-turns instead, so leaving this at the default makes
298+
# the two harnesses incomparable. `build` is opencode's primary agent.
299+
payload: dict[str, object] = {
300+
"agent": {"build": {"steps": OPENCODE_STEP_LIMIT}}
301+
}
302+
249303
provider, _, _ = model.partition("/")
250-
if provider == "openai":
251-
return [] # the adapter already injects the baseURL for this one
252-
path = task / "environment/gateway/launch.json"
253-
if not path.exists():
304+
if "/" in model and provider != "openai":
305+
# The adapter injects a baseURL only for the openai provider; for any
306+
# other it writes none and opencode calls that provider's public
307+
# endpoint. Supply it ourselves so the traffic stays metered.
308+
path = task / "environment/gateway/launch.json"
309+
if path.exists():
310+
try:
311+
base_url = json.loads(path.read_text(encoding="utf-8"))[
312+
"producer_base_url"
313+
]
314+
except (OSError, json.JSONDecodeError, KeyError):
315+
base_url = None
316+
if base_url:
317+
# producer_base_url ends in /v1 and provider SDKs append their
318+
# own route (/messages), matching ANTHROPIC_BASE_URL for
319+
# claude-code.
320+
payload["provider"] = {provider: {"options": {"baseURL": base_url}}}
321+
return ["--ak", f"opencode_config={json.dumps(payload, separators=(',', ':'))}"]
322+
323+
324+
def _outer_app_name_args(
325+
environment: str, config_name: str, extra: tuple[str, ...]
326+
) -> list[str]:
327+
"""Name the outer trial's Modal app so its sandbox can be found.
328+
329+
Inner evaluation sandboxes are already grouped by an explicit
330+
``--ek app_name=...`` in each build's ``extra_harbor_args``, but the outer
331+
trial had none and so landed in Modal's default ``__harbor__`` app. That
332+
costs twice: the workspace holds thousands of containers, so an unnamed outer
333+
sandbox is effectively unfindable in the UI, and recovering the session from
334+
a run that must be killed begins with identifying its container.
335+
336+
Derived from the build name (``vero/optimize-gaia-baseline`` ->
337+
``vero-optimize-gaia-baseline``) so outer trials group per benchmark. A
338+
caller passing its own ``--ek app_name=`` wins.
339+
"""
340+
341+
if environment != "modal":
254342
return []
255-
try:
256-
base_url = json.loads(path.read_text(encoding="utf-8"))["producer_base_url"]
257-
except (OSError, json.JSONDecodeError, KeyError):
343+
if any("app_name=" in argument for argument in extra):
258344
return []
259-
# producer_base_url ends in /v1 and provider SDKs append their own route
260-
# (/messages), matching how ANTHROPIC_BASE_URL is handed to claude-code.
261-
payload = {"provider": {provider: {"options": {"baseURL": base_url}}}}
262-
return ["--ak", f"opencode_config={json.dumps(payload, separators=(',', ':'))}"]
345+
slug = re.sub(r"[^a-zA-Z0-9_.-]+", "-", config_name).strip("-")
346+
return ["--ek", f"app_name={slug or 'vero'}"]
263347

264348

265349
def _compiled_run_environment(
@@ -611,9 +695,21 @@ def run_command(config_path, agent, model, environment, params, env_file, extra)
611695
for key in sorted(config.agent_env):
612696
command.extend(["--ae", f"{key}={config.agent_env[key]}"])
613697
command.extend(_opencode_gateway_args(agent, model, task))
698+
command.extend(_litellm_base_url_args(agent, task))
614699
# Build-declared outer-trial flags first, so a command-line arg can still
615700
# override them (harbor's `--ek` takes the last value for a key).
616701
command.extend(config.optimizer_harbor_args)
702+
# The derived app name defers to an explicit one from *either* source: a
703+
# build may declare `--ek app_name=` in optimizer_harbor_args just as a
704+
# caller may pass it on the command line, and appending ours after the
705+
# build's would silently win on harbor's last-value-per-key rule.
706+
command.extend(
707+
_outer_app_name_args(
708+
environment,
709+
config.name,
710+
(*config.optimizer_harbor_args, *extra),
711+
)
712+
)
617713
command.extend(extra)
618714
click.echo(shlex.join(command))
619715
completed = subprocess.run(
@@ -674,7 +770,11 @@ def inference_gateway_command(config_path, host, port):
674770
@harbor.command("eval")
675771
@click.option(
676772
"--backend", "backend_id", required=True,
677-
help="Evaluation backend to score against (e.g. the selection partition's backend; see `evals plan`).",
773+
help=(
774+
"Evaluation backend to score against. Must match --partition: each "
775+
"partition is served by exactly one backend and asking a different one "
776+
"is denied. `evals plan` lists the pair for every partition you may run."
777+
),
678778
)
679779
@click.option(
680780
"--evaluation-set", "evaluation_set_name", required=True,

vero/src/vero/runtime/context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,10 @@ async def write_evaluation_plan(
447447
{
448448
"name": entry.evaluation_set.name,
449449
"partition": entry.evaluation_set.partition,
450+
# Which backend serves this partition. `evals run` needs the
451+
# pair to match — asking one partition's backend for another
452+
# is denied — and `--backend`'s help points here for it.
453+
"backend": entry.backend_id,
450454
"cases": case_count,
451455
"base_selection": entry.evaluation_set.selection.model_dump(
452456
mode="json"

vero/src/vero/skills/evals/SKILL.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ subcommand.
1717
## The loop
1818

1919
```bash
20-
evals plan # what you may run, partition sizes, budget
20+
evals plan # backend+partition pairs, sizes, budget
2121
evals run --backend B --evaluation-set S --partition P # BLOCKS, returns the result
2222
evals list --sort score --desc # every past result, one row each
2323
evals diff BASELINE_ID CANDIDATE_ID # which cases improved / regressed
@@ -105,6 +105,10 @@ are enforced by the evaluator; `evals plan` shows what remains.
105105
disk under `.evals/results/`; read it back with `evals show` / `evals cases`.
106106
- Confusing ids: evaluation ids come from `evals list`; case ids from
107107
`evals cases`; job ids only from `evals run --detach`.
108+
- Mismatching `--backend` and `--partition`. Each partition is served by exactly
109+
one backend, and asking a different one is refused as `evaluation denied` — a
110+
denial is deliberately opaque, so it will not tell you that is the reason. Take
111+
both from the same row of `evals plan`.
108112
- Scores may be noisy: replicate an important comparison before acting on it, by
109113
re-running the identical case selection.
110114
- Backgrounding the wait. Putting `evals run`/`evals wait` in a background task

vero/tests/test_v05_agent_context.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ async def resolve_cost(self, evaluation_set):
288288
)
289289

290290
plan = json.loads((tmp_path / "ctx" / "plan.json").read_text(encoding="utf-8"))
291+
# The backend serving each partition, so `evals run --backend/--partition`
292+
# can be taken from one row. Mismatching them is refused as an opaque
293+
# "evaluation denied", which cost swe-atlas-qna's optimizer a round trip.
294+
assert {row["partition"]: row["backend"] for row in plan["evaluations"]} == {
295+
"development": "harbor-development",
296+
"validation": "harbor-validation",
297+
}
291298
sizes = {row["partition"]: row["cases"] for row in plan["evaluations"]}
292299
# A backend that cannot cost itself degrades to no count, not a broken plan.
293300
assert sizes == {"development": 49, "validation": None}

vero/tests/test_v05_cli.py

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -334,21 +334,118 @@ def test_opencode_non_openai_provider_gets_a_gateway_base_url(tmp_path):
334334
assert args[0] == "--ak"
335335
key, _, value = args[1].partition("=")
336336
assert key == "opencode_config"
337-
assert json.loads(value) == {
338-
"provider": {
339-
"anthropic": {
340-
"options": {"baseURL": "http://inference-gateway:8001/scopes/p/o/v1"}
341-
}
337+
payload = json.loads(value)
338+
assert payload["provider"] == {
339+
"anthropic": {
340+
"options": {"baseURL": "http://inference-gateway:8001/scopes/p/o/v1"}
342341
}
343342
}
344-
345-
# openai is the one provider the adapter already handles; don't fight it.
346-
assert _opencode_gateway_args("opencode", "openai/gpt-5.4", task) == []
347-
# Other agents and bare model names are untouched.
343+
# The same config carries the step limit; see the step-limit test below.
344+
assert payload["agent"]["build"]["steps"] > 100
345+
346+
# openai is the one provider the adapter already handles for baseURL; don't
347+
# fight it -- but opencode still needs its step limit raised.
348+
openai = _opencode_gateway_args("opencode", "openai/gpt-5.4", task)
349+
assert "provider" not in json.loads(openai[1].removeprefix("opencode_config="))
350+
# Other agents are untouched -- claude-code controls turns via --max-turns.
348351
assert _opencode_gateway_args("claude-code", "anthropic/claude-sonnet-5", task) == []
349-
assert _opencode_gateway_args("opencode", "claude-sonnet-5", task) == []
350-
# A task compiled without a gateway simply gets no override.
351-
assert _opencode_gateway_args("opencode", "anthropic/x", tmp_path / "none") == []
352+
# For opencode the step limit is unconditional: it does not depend on the
353+
# model spelling or on a gateway being present, because a truncated search is
354+
# a problem either way. Only the baseURL injection is conditional.
355+
bare = _opencode_gateway_args("opencode", "claude-sonnet-5", task)
356+
assert json.loads(bare[1].removeprefix("opencode_config="))["agent"]["build"][
357+
"steps"
358+
] > 100
359+
no_gateway = _opencode_gateway_args("opencode", "anthropic/x", tmp_path / "none")
360+
payload = json.loads(no_gateway[1].removeprefix("opencode_config="))
361+
assert payload["agent"]["build"]["steps"] > 100
362+
assert "provider" not in payload
363+
364+
365+
def test_outer_modal_trial_gets_a_named_app():
366+
"""The outer trial must not land in Modal's anonymous default app.
367+
368+
Inner eval sandboxes are already grouped by app_name via each build's
369+
extra_harbor_args, but the outer trial had none, so it went to __harbor__
370+
alongside every other workspace container -- ~1800 of them -- which makes it
371+
unfindable in the UI and turns "copy the session out before killing this run"
372+
into a search problem.
373+
"""
374+
from vero.harbor.cli import _outer_app_name_args
375+
376+
assert _outer_app_name_args("modal", "vero/optimize-gaia-baseline", ()) == [
377+
"--ek",
378+
"app_name=vero-optimize-gaia-baseline",
379+
]
380+
# Docker outer trials are found via `docker ps`; no app applies.
381+
assert _outer_app_name_args("docker", "vero/optimize-gaia-baseline", ()) == []
382+
# An explicit choice wins.
383+
assert _outer_app_name_args(
384+
"modal", "vero/optimize-gaia-baseline", ("--ek", "app_name=mine")
385+
) == []
386+
# A name that is entirely punctuation still yields a usable app.
387+
assert _outer_app_name_args("modal", "///", ()) == ["--ek", "app_name=vero"]
388+
389+
390+
def test_opencode_gets_a_step_limit_that_does_not_truncate_the_search(tmp_path):
391+
"""opencode caps agentic iterations at 100 and then forces a text-only reply.
392+
393+
That is inside a real run: gaia's optimizer used exactly 100 and stopped
394+
without ever calling `evals submit`, and because the cap produces a fluent
395+
final message the truncation is invisible unless you count the steps.
396+
claude-code takes harbor's --max-turns instead, so leaving opencode at its
397+
default makes the two harnesses incomparable on the one axis the grid varies.
398+
"""
399+
from vero.harbor.cli import OPENCODE_STEP_LIMIT, _opencode_gateway_args
400+
401+
args = _opencode_gateway_args("opencode", "anthropic/claude-sonnet-5", tmp_path)
402+
assert args[0] == "--ak"
403+
payload = json.loads(args[1].removeprefix("opencode_config="))
404+
assert payload["agent"]["build"]["steps"] == OPENCODE_STEP_LIMIT
405+
assert OPENCODE_STEP_LIMIT > 100
406+
407+
# Still set for the openai provider, which needs no baseURL injection.
408+
openai = json.loads(
409+
_opencode_gateway_args("opencode", "openai/gpt-5.4", tmp_path)[1]
410+
.removeprefix("opencode_config=")
411+
)
412+
assert openai["agent"]["build"]["steps"] == OPENCODE_STEP_LIMIT
413+
assert "provider" not in openai
414+
415+
# claude-code is untouched; it has its own turn control.
416+
assert _opencode_gateway_args("claude-code", "claude-sonnet-5", tmp_path) == []
417+
418+
419+
def test_litellm_harnesses_get_the_gateway_url_under_the_name_they_read(tmp_path):
420+
"""litellm reads <PROVIDER>_API_BASE; the provider SDKs read _BASE_URL.
421+
422+
vero sets the SDK names, so mini-swe-agent -- which drives the model through
423+
litellm[proxy] -- saw no override and called api.anthropic.com holding only a
424+
scoped gateway token: AuthenticationError, invalid x-api-key. It fails closed,
425+
so nothing leaked, but the harness could not run at all.
426+
"""
427+
from vero.harbor.cli import _litellm_base_url_args
428+
429+
task = tmp_path / "task"
430+
(task / "environment" / "gateway").mkdir(parents=True)
431+
(task / "environment" / "gateway" / "launch.json").write_text(
432+
json.dumps({"producer_base_url": "http://inference-gateway:8001/scopes/p/o/v1"}),
433+
encoding="utf-8",
434+
)
435+
436+
args = _litellm_base_url_args("mini-swe-agent", task)
437+
pairs = dict(zip(args[::2], args[1::2]))
438+
assert set(pairs) == {"--ae"} or True # flags interleave; check the values
439+
values = [v for k, v in zip(args[::2], args[1::2])]
440+
assert "OPENAI_API_BASE=http://inference-gateway:8001/scopes/p/o/v1" in values
441+
assert "ANTHROPIC_API_BASE=http://inference-gateway:8001/scopes/p/o/v1" in values
442+
443+
# Harnesses that use provider SDKs already get _BASE_URL and need nothing.
444+
assert _litellm_base_url_args("claude-code", task) == []
445+
assert _litellm_base_url_args("opencode", task) == []
446+
# No compiled gateway: nothing to point at.
447+
assert _litellm_base_url_args("mini-swe-agent", tmp_path / "none") == []
448+
352449

353450

354451
class _Gateway:
@@ -516,6 +613,9 @@ class _Config:
516613
agent_env: dict[str, str] = {}
517614
optimizer_harbor_args = ["--ek", "modal_vm_runtime=true"]
518615
extra_harbor_args = ["--ek", "app_name=nested-only"]
616+
# Real configs always carry a name; the outer trial derives its Modal app
617+
# name from it so the sandbox is findable in a workspace of thousands.
618+
name = "vero/stub-benchmark"
519619

520620
monkeypatch.setattr(harbor_build, "load_harbor_build_config", lambda *a, **k: _Config())
521621
monkeypatch.setattr(harbor_build, "compile_harbor_task", lambda config, output: output)

0 commit comments

Comments
 (0)