From 01c48687c5fc2c93c2f79a92c5a5825b41d6a639 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 30 Jun 2026 18:47:16 +0100 Subject: [PATCH 1/2] feat(gateway): answer with the decision, not a 500, when no model is configured [roadmap:WF-ROADMAP-0007] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WF-ADR-0042: a LIVE gateway with no [gateway.models] now returns the routing decision ({"wayfinder": {...decision..., "decision_only": true}} + an x-wayfinder-router-decision-only header) from /v1/chat/completions instead of a 500. This is the onboarding floor for the desktop app and the bare `serve` command: you see real routing the instant the gateway is up — before wiring any backend — and a client can render decisions while a local model (Ollama) is still warming up. Only DELIVERY is skipped; the decision is computed offline by the same path as `--dry-run`, so it is byte-identical to the dry-run decision (WF-ADR-0001 — no model call, key, or network enters the scored path). The branch is gated strictly on an empty model set, placed after the dry-run return (which keeps its own `dry_run` flag) and before the endpoint lookup; a genuine outage (models configured but all cooling down) still returns its 503, and a misconfigured tier still 500s. The no-models boot warning is updated to match (requests now return decisions, not failures). Tests: empty-models live -> 200 decision_only with zero upstream calls; dry-run still flags dry_run; the two decisions match field-for-field; the misconfig 500 is unaffected. --- CHANGELOG.md | 8 ++++++ tests/test_gateway.py | 53 +++++++++++++++++++++++++++++++++++++ wayfinder_router/gateway.py | 18 +++++++++++-- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01546277..5bf13681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,14 @@ 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`. + ## v2026.6.10 — 2026-06-29 The **feedback release** — features driven by post-launch feedback. diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 9d9451c5..687adbec 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -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) diff --git a/wayfinder_router/gateway.py b/wayfinder_router/gateway.py index ee03783f..45021bdd 100644 --- a/wayfinder_router/gateway.py +++ b/wayfinder_router/gateway.py @@ -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( @@ -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) From d91f232c273ae78ae29e10d348d7143060283d12 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 30 Jun 2026 18:55:01 +0100 Subject: [PATCH 2/2] feat(gateway): add --config / WAYFINDER_CONFIG so a service-managed gateway loads a fixed file [roadmap:WF-ROADMAP-0007] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WF-ADR-0042: the gateway discovers its config by walking up from the working directory, but a launchd / systemd-launched gateway has an unpredictable cwd — and the desktop app wants the app and the service to share one well-known file (~/Library/Application Support/Wayfinder/wayfinder-router.toml). Add an explicit override: - config.find_config_file consults WAYFINDER_CONFIG first; a set-but-missing override resolves to None (a clear "your configured file isn't there") rather than silently walking up to another config above the cwd. - `serve --config PATH` sets WAYFINDER_CONFIG for the process; `service install --config PATH` threads it through _resolve_serve_args into the unit's ProgramArguments so the service loads a fixed file regardless of where it was launched. Precedence: --config -> WAYFINDER_CONFIG -> cwd walk-up -> built-in defaults. Fully backward-compatible (unset == the unchanged behavior). No change to the deterministic core (WF-ADR-0001). Tests: env override wins over walk-up; a missing override -> None (not a walk-up); unset -> walk-up unchanged; --config appears in the generated unit. Verified live: both `serve --config` and WAYFINDER_CONFIG load a config from a foreign cwd (healthz lists the configured model). --- CHANGELOG.md | 9 +++++++++ tests/test_config.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_service.py | 19 +++++++++++++++++++ wayfinder_router/cli.py | 31 +++++++++++++++++++++++++++---- wayfinder_router/config.py | 17 ++++++++++++++++- 5 files changed, 105 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf13681..6aad5c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,15 @@ offline (WF-ADR-0001). 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 ` (or set `WAYFINDER_CONFIG`). `service install --config ` 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. diff --git a/tests/test_config.py b/tests/test_config.py index 9ef957e4..d7236866 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,7 @@ 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 @@ -12,6 +12,7 @@ @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: @@ -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 ------------------------------------------------------------------ diff --git a/tests/test_service.py b/tests/test_service.py index 5e8aecf0..4b045d87 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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 "--config" in out + assert "/etc/wf/wayfinder-router.toml" 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. diff --git a/wayfinder_router/cli.py b/wayfinder_router/cli.py index e61cbb23..9f7677e7 100644 --- a/wayfinder_router/cli.py +++ b/wayfinder_router/cli.py @@ -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=".", @@ -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: @@ -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 @@ -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( @@ -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.", diff --git a/wayfinder_router/config.py b/wayfinder_router/config.py index 9bd0f768..fe0ba17d 100644 --- a/wayfinder_router/config.py +++ b/wayfinder_router/config.py @@ -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" @@ -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