Skip to content
Open
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ offline (WF-ADR-0001).
`X-Wayfinder-Sticky` / `X-Wayfinder-Sticky-Cooldown` headers — so what the status bar shows is what
routes, identically across both backends. Still pure and offline (WF-ADR-0001).

- **Decision-only replies when no model is configured** (WF-ADR-0042). A running gateway with no
`[gateway.models]` now answers `/v1/chat/completions` with the routing **decision** (HTTP 200,
`{"wayfinder": {…}}` and an `x-wayfinder-router-decision-only: true` header) instead of a `500` — so
you see real routing the moment the gateway starts, before wiring any backend, and a client can
render decisions while a local model is still warming up. Only delivery is skipped; the decision is
computed offline and is identical to `--dry-run` (WF-ADR-0001). A genuine outage (models configured
but all cooling down) still returns its `503`.

- **`serve --config` / `WAYFINDER_CONFIG`** (WF-ADR-0042). Point the gateway at a specific
`wayfinder-router.toml` instead of relying on the working-directory search: `wayfinder-router serve
--config <path>` (or set `WAYFINDER_CONFIG`). `service install --config <path>` bakes it into the
launchd / systemd unit, so a service-managed gateway loads a fixed file regardless of where it was
launched — and a desktop app and the service can share one well-known config. Precedence is
`--config` → `WAYFINDER_CONFIG` → the existing cwd walk-up → built-in defaults; an
explicitly-configured-but-missing file is a clear "not found" (never a silent fallback to some other
file). Fully backward-compatible — unset means the unchanged behavior.

## v2026.6.10 — 2026-06-29

The **feedback release** — features driven by post-launch feedback.
Expand Down
35 changes: 34 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import pytest
from wayfinder_router.complexity import DEFAULT_THRESHOLD
from wayfinder_router.config import THRESHOLD_ENV
from wayfinder_router.config import CONFIG_PATH_ENV, THRESHOLD_ENV, find_config_file

from wayfinder_router import RoutingConfig, WayfinderConfigError, load_routing_config


@pytest.fixture(autouse=True)
def _clear_env(monkeypatch):
monkeypatch.delenv(THRESHOLD_ENV, raising=False)
monkeypatch.delenv(CONFIG_PATH_ENV, raising=False)


def _write(tmp_path, body: str) -> str:
Expand Down Expand Up @@ -65,6 +66,38 @@ def test_config_is_discovered_by_walking_up(tmp_path):
assert load_routing_config(str(nested)).tiers[1].min_score == 0.9


# --- WAYFINDER_CONFIG override (WF-ADR-0042) --------------------------------


def test_wayfinder_config_env_overrides_walk_up(tmp_path, monkeypatch):
# An explicit WAYFINDER_CONFIG wins over any wayfinder-router.toml found by walking up.
walked = tmp_path / "walked"
walked.mkdir()
_write(walked, "[routing]\nthreshold = 0.9\n") # would be found by the walk-up
chosen = tmp_path / "elsewhere" / "wayfinder-router.toml"
chosen.parent.mkdir()
chosen.write_text("[routing]\nthreshold = 0.2\n", encoding="utf-8")
monkeypatch.setenv(CONFIG_PATH_ENV, str(chosen))
assert find_config_file(str(walked)).samefile(chosen)
assert load_routing_config(str(walked)).tiers[1].min_score == 0.2


def test_wayfinder_config_env_missing_file_is_none_not_walk_up(tmp_path, monkeypatch):
# A configured-but-absent override is a clear None, never a silent walk-up to another file.
_write(tmp_path, "[routing]\nthreshold = 0.9\n") # present, but must be ignored
monkeypatch.setenv(CONFIG_PATH_ENV, str(tmp_path / "does-not-exist.toml"))
assert find_config_file(str(tmp_path)) is None
assert load_routing_config(str(tmp_path)).tiers[1].min_score == DEFAULT_THRESHOLD


def test_wayfinder_config_unset_uses_walk_up(tmp_path):
# With the env unset (the autouse fixture clears it), behaviour is the unchanged walk-up.
_write(tmp_path, "[routing]\nthreshold = 0.77\n")
nested = tmp_path / "x" / "y"
nested.mkdir(parents=True)
assert find_config_file(str(nested)).samefile(tmp_path / "wayfinder-router.toml")


# --- tiers ------------------------------------------------------------------


Expand Down
53 changes: 53 additions & 0 deletions tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,59 @@ def test_unconfigured_model_is_a_clear_misconfig_error(tmp_path, monkeypatch):
assert resp.json()["error"]["type"] == "wayfinder_router_misconfigured"


# --- Decision-only degrade: a no-models live gateway answers with the decision (WF-ADR-0042) ---

_NO_MODELS_CONFIG = "[routing]\nthreshold = 0.2\n"


def test_no_models_live_returns_decision_only(tmp_path, monkeypatch):
# The onboarding cold-start: a LIVE gateway with no [gateway.models] returns the routing
# decision (HTTP 200) instead of a 500, and never contacts an upstream.
(tmp_path / "wayfinder-router.toml").write_text(_NO_MODELS_CONFIG, encoding="utf-8")
called = False

async def fail_aforward(*a, **k):
nonlocal called
called = True
return 200, b"{}", "application/json"

monkeypatch.setattr(gateway, "aforward_request", fail_aforward)
test_client = TestClient(gateway.build_app(start_dir=str(tmp_path)))
resp = test_client.post("/v1/chat/completions", json=COMPLEX)
assert resp.status_code == 200
assert resp.headers["x-wayfinder-router-decision-only"] == "true"
body = resp.json()["wayfinder"]
assert body["decision_only"] is True
assert "dry_run" not in body
assert body["model"] and isinstance(body["score"], (int, float))
assert called is False # no upstream was contacted — the decision stays offline (WF-ADR-0001)


def test_dry_run_still_flags_dry_run_not_decision_only(tmp_path, monkeypatch):
# An explicit --dry-run (models configured) keeps its own flag; it is not decision-only.
(tmp_path / "wayfinder-router.toml").write_text(CONFIG, encoding="utf-8")
monkeypatch.setattr(gateway, "aforward_request", _ok_aforward)
test_client = TestClient(gateway.build_app(start_dir=str(tmp_path), dry_run=True))
resp = test_client.post("/v1/chat/completions", json=COMPLEX)
body = resp.json()["wayfinder"]
assert body["dry_run"] is True
assert "decision_only" not in body
assert "x-wayfinder-router-decision-only" not in resp.headers


def test_no_models_live_decision_matches_dry_run(tmp_path, monkeypatch):
# The decision a no-models live gateway returns is identical to the dry-run decision for the
# same prompt+config — only DELIVERY is skipped, the decision is unchanged (WF-ADR-0001).
(tmp_path / "wayfinder-router.toml").write_text(_NO_MODELS_CONFIG, encoding="utf-8")
monkeypatch.setattr(gateway, "aforward_request", _ok_aforward)
live = TestClient(gateway.build_app(start_dir=str(tmp_path)))
dry = TestClient(gateway.build_app(start_dir=str(tmp_path), dry_run=True))
lw = live.post("/v1/chat/completions", json=COMPLEX).json()["wayfinder"]
dw = dry.post("/v1/chat/completions", json=COMPLEX).json()["wayfinder"]
assert (lw["model"], lw["score"], lw["features"]) == (dw["model"], dw["score"], dw["features"])
assert lw["decision_only"] is True and dw["dry_run"] is True


def test_response_body_is_relayed_unchanged(client):
test_client, _ = client
resp = test_client.post("/v1/chat/completions", json=TRIVIAL)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ def fake_run(cmd, *a, **k):
assert "could not enable" in err and "permission denied" in err


def test_resolve_serve_args_includes_config_when_given():
args = _resolve_serve_args("127.0.0.1", 8088, "/etc/wf/wayfinder-router.toml")
assert args[-2:] == ["--config", "/etc/wf/wayfinder-router.toml"]
assert "--config" not in _resolve_serve_args("127.0.0.1", 8088) # absent by default


def test_service_install_print_bakes_config_into_the_unit(monkeypatch, capsys):
# `service install --config PATH --print` threads --config into the unit's ProgramArguments,
# so a launchd / systemd gateway loads a fixed file regardless of its working directory.
monkeypatch.setattr(service, "detect_platform", lambda platform=None: "macos")
args = build_parser().parse_args(
["service", "install", "--print", "--config", "/etc/wf/wayfinder-router.toml"]
)
assert args.func(args) == 0
out = capsys.readouterr().out
assert "<string>--config</string>" in out
assert "<string>/etc/wf/wayfinder-router.toml</string>" in out


def test_service_install_resolves_an_absolute_log_path(monkeypatch, capsys):
# launchd cannot expand ``~`` in StandardOutPath/StandardErrorPath; an unresolved tilde
# makes the agent fail to spawn (EX_CONFIG). The CLI must emit an absolute log path.
Expand Down
31 changes: 27 additions & 4 deletions wayfinder_router/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ def _cmd_recalibrate(args: argparse.Namespace) -> int:


def _cmd_serve(args: argparse.Namespace) -> int:
import os

from .config import CONFIG_PATH_ENV
from .gateway import GatewayUnavailable, run

if args.config:
os.environ[CONFIG_PATH_ENV] = args.config
try:
run(
start_dir=".",
Expand Down Expand Up @@ -737,13 +742,21 @@ def on_verdict(prompt: str, outputs: dict, verdict) -> None:
return EXIT_OK


def _resolve_serve_args(host: str, port: int) -> list[str]:
"""ProgramArguments to launch the gateway: the installed console script, else ``python -m``."""
def _resolve_serve_args(host: str, port: int, config: str | None = None) -> list[str]:
"""ProgramArguments to launch the gateway: the installed console script, else ``python -m``.

When ``config`` is given it is appended as ``--config PATH`` so a service-managed gateway
(launchd / systemd, whose working directory is unpredictable) loads a fixed file rather than
walking up from its cwd (WF-ADR-0042).
"""
import shutil

exe = shutil.which("wayfinder-router")
base = [exe] if exe else [sys.executable, "-m", "wayfinder_router.cli"]
return [*base, "serve", "--host", host, "--port", str(port)]
serve_args = [*base, "serve", "--host", host, "--port", str(port)]
if config:
serve_args += ["--config", config]
return serve_args


def _probe_health(host: str, port: int) -> str:
Expand Down Expand Up @@ -785,7 +798,7 @@ def _cmd_service(args: argparse.Namespace) -> int:
)
return EXIT_USAGE

program_args = _resolve_serve_args(args.host, args.port)
program_args = _resolve_serve_args(args.host, args.port, args.config)
endpoint = f"http://{args.host}:{args.port}/v1"
# launchd does not expand ``~`` in StandardOutPath/StandardErrorPath — an unresolved tilde
# makes it fail to open the log file and refuse to spawn (EX_CONFIG). Resolve the log dir to
Expand Down Expand Up @@ -994,6 +1007,11 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="Upstream request timeout in seconds (default: WAYFINDER_ROUTER_TIMEOUT or 60).",
)
p_serve.add_argument(
"--config",
default=None,
help="Path to wayfinder-router.toml (overrides the cwd search; sets WAYFINDER_CONFIG).",
)
p_serve.set_defaults(func=_cmd_serve)

p_service = sub.add_parser(
Expand All @@ -1005,6 +1023,11 @@ def build_parser() -> argparse.ArgumentParser:
)
p_service.add_argument("--host", default="127.0.0.1", help="Gateway host (default: 127.0.0.1).")
p_service.add_argument("--port", type=int, default=8088, help="Gateway port (default: 8088).")
p_service.add_argument(
"--config", default=None,
help="Path to wayfinder-router.toml baked into the unit, so the service loads a fixed file "
"regardless of its working directory.",
)
p_service.add_argument(
"--print", action="store_true",
help="Print the generated unit file instead of installing it.",
Expand Down
17 changes: 16 additions & 1 deletion wayfinder_router/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
_MAX_LEXICON_TERMS = 2000

CONFIG_FILE = "wayfinder-router.toml"
# An explicit path to the config file, overriding the cwd walk-up. Lets a launchd-spawned gateway
# (whose cwd is unpredictable) and the desktop app agree on one well-known file — e.g.
# ~/Library/Application Support/Wayfinder/wayfinder-router.toml (WF-ADR-0042). `serve --config PATH`
# sets it.
CONFIG_PATH_ENV = "WAYFINDER_CONFIG"
# Convenience override for one-off runs of the binary router without editing the
# file. Ignored when explicit tiers or a classifier are configured.
THRESHOLD_ENV = "WAYFINDER_ROUTER_THRESHOLD"
Expand All @@ -51,7 +56,17 @@ class WayfinderConfigError(Exception):


def find_config_file(start_dir: str) -> Path | None:
"""The nearest ``wayfinder-router.toml`` at or above ``start_dir``, or None."""
"""The config file to load: an explicit ``WAYFINDER_CONFIG`` override, else the nearest
``wayfinder-router.toml`` at or above ``start_dir``, else None.

The override is absolute: when ``WAYFINDER_CONFIG`` is set but the file is missing, the result
is ``None`` (a clear "your configured file isn't there"), never a silent walk-up to some other
config that happens to be above the cwd.
"""
override = os.environ.get(CONFIG_PATH_ENV)
if override:
path = Path(override).expanduser()
return path if path.is_file() else None
current = Path(start_dir).resolve()
for directory in (current, *current.parents):
candidate = directory / CONFIG_FILE
Expand Down
18 changes: 16 additions & 2 deletions wayfinder_router/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,8 @@ def _record_turn(
logger.warning("gateway model '%s' references unset env var %s", name, model.api_key_env)
if not gw0.models and not dry_run:
logger.warning(
"no [gateway.models] configured; requests will fail until you add an endpoint "
"(or run with --dry-run to see routing decisions without backends)"
"no [gateway.models] configured; requests return routing decisions only "
"(decision-only, WF-ADR-0042) until you add an endpoint — add one to get replies"
)
if feedback_token is None:
logger.info(
Expand Down Expand Up @@ -2134,6 +2134,20 @@ def _explain_payload() -> dict:
headers=wf_headers,
)

# Decision-only degrade (WF-ADR-0042): a LIVE gateway with no models configured at all
# answers with the routing decision (like a dry run) instead of a 500, so onboarding can
# show real routing before any backend exists, and the desktop app can render decisions
# while a local model (e.g. Ollama) is still starting. Only DELIVERY is skipped — the
# decision is computed offline and unchanged (WF-ADR-0001). This is distinct from the
# breaker/offline 503 below ("models exist but are cooling down"), which stays a real error.
if not gw.models:
wf_headers["x-wayfinder-router-decision-only"] = "true"
return JSONResponse(
status_code=200,
content={"wayfinder": {**_explain_payload(), "decision_only": True}},
headers=wf_headers,
)

target = gw.models.get(chosen)
if target is None:
logger.error("request %s: no endpoint configured for model '%s'", request_id, chosen)
Expand Down
Loading