Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/agent_harness/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@

from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
computed_field,
field_validator,
model_validator,
)

# --------------------------------------------------------------------- work

Expand Down Expand Up @@ -170,7 +177,19 @@ class SetFleetControl(BaseModel):


class RoleRoute(BaseModel):
model: str = Field(description="Model identifier as the provider names it.")
model: str = Field(
"",
description="Model identifier as the provider names it. The PREFERRED "
"one when `models` names several; filled in from the first of them "
"when omitted.",
)
models: list[str] = Field(
default_factory=list,
description="Models to try for this role, in preference order. The "
"first that answers does the work; the rest are tried only when it "
"will not, and the whole list is tried before any backoff. Omit for a "
"single model — `model` alone still works and always has.",
)
endpoint: str = Field(description="Base URL of the provider API.")
provider: str = Field(
"claw-bay",
Expand All @@ -179,6 +198,28 @@ class RoleRoute(BaseModel):
"limit, because nothing in HTTP can.",
)

@model_validator(mode="after")
def one_source_of_truth(self) -> RoleRoute:
"""Keep `model` and `models` from disagreeing.

They are two views of one thing, and a route where they contradict
each other is a route whose behaviour depends on which field a reader
happens to consult -- exactly the ambiguity that made the old
single-field map unable to express a fallback at all.
"""
if self.models and not self.model:
self.model = self.models[0]
elif self.model and not self.models:
self.models = [self.model]
elif not self.model and not self.models:
raise ValueError("a role needs a model: set `model`, or `models` in preference order")
elif self.models[0] != self.model:
raise ValueError(
f"`model` is {self.model!r} but `models` prefers {self.models[0]!r}; "
"`model` is the preferred route, so either match it or omit it"
)
return self


class RoutedRole(RoleRoute):
"""A route, and whether this deployment's executor ever calls it."""
Expand Down
52 changes: 52 additions & 0 deletions tests/test_model_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,55 @@ def test_a_map_written_before_fallbacks_existed_still_reads() -> None:

def test_a_role_with_no_endpoint_is_dropped_rather_than_half_built() -> None:
assert chains_from_map({"reviewer": {"models": ["gpt-5.6"]}}) == {}


# --------------------------------------------------- configuring it remotely


def test_the_api_schema_can_express_a_chain() -> None:
"""The gap this closes: chains shipped in the CLI and the stored map, and
`RoleRoute` — the only way to configure a deployed pod — could not say
them. A feature unconfigurable where it is used is not shipped."""
from agent_harness.schemas import RoleRoute

route = RoleRoute(models=["deepseek-v4-flash", "glm-5.2", "gpt-5.4"], endpoint=ENDPOINT)

assert route.model == "deepseek-v4-flash", "`model` is the preferred one"
assert [
r.model for r in chains_from_map({"implementer": route.model_dump()})["implementer"]
] == [
"deepseek-v4-flash",
"glm-5.2",
"gpt-5.4",
]


def test_a_single_model_still_works_unchanged() -> None:
from agent_harness.schemas import RoleRoute

route = RoleRoute(model="gpt-5.6", endpoint=ENDPOINT)

assert route.models == ["gpt-5.6"]
assert [r.model for r in chains_from_map({"reviewer": route.model_dump()})["reviewer"]] == [
"gpt-5.6"
]


def test_a_route_whose_two_fields_disagree_is_refused() -> None:
"""They are two views of one thing. A route where they contradict behaves
differently depending on which field a reader consults."""
from pydantic import ValidationError

from agent_harness.schemas import RoleRoute

with pytest.raises(ValidationError, match="preferred route"):
RoleRoute(model="gpt-5.6", models=["gpt-5.4", "gpt-5.6"], endpoint=ENDPOINT)


def test_a_route_naming_no_model_at_all_is_refused() -> None:
from pydantic import ValidationError

from agent_harness.schemas import RoleRoute

with pytest.raises(ValidationError, match="a role needs a model"):
RoleRoute(endpoint=ENDPOINT)
Loading