diff --git a/switchyard/cli/configure_command.py b/switchyard/cli/configure_command.py index 20eda6eb..589a18d0 100644 --- a/switchyard/cli/configure_command.py +++ b/switchyard/cli/configure_command.py @@ -367,13 +367,15 @@ def cmd_configure(request: ConfigureRequest) -> None: print("No Switchyard user config found.") return - provider = request.provider target_scope = request.target configure_claude = target_scope in ("all", "claude") configure_codex = target_scope in ("all", "codex") configure_openclaw = target_scope in ("all", "openclaw") existing_config = load_user_config() existing_credentials = load_user_credentials() + # A CLI --provider wins; otherwise honor the saved default_provider so the + # save path doesn't overwrite it with the argparse fallback. + provider = request.provider or existing_config.default_provider skill_distillation = _apply_skill_distillation_args( existing_config.skill_distillation, request, @@ -417,9 +419,26 @@ def cmd_configure(request: ConfigureRequest) -> None: api_key = request.api_key if not api_key: existing_api_key = existing_credentials.api_key(provider) + # Fall back to an env/secrets key so non-interactive `configure` (no + # --api-key, no saved key) picks up e.g. OPENROUTER_API_KEY instead of + # erroring out that it "requires an API key". Scope the env vars to the + # selected provider so `configure --provider nvidia` can't save an + # OPENROUTER_API_KEY under `nvidia`; if the provider's own key isn't set + # we fall through and require --api-key. + env_prefix = provider.upper().replace("-", "_") + env_api_key = resolve_provider_connectivity( + cli_api_key=None, + cli_base_url=None, + api_key_env_vars=(f"{env_prefix}_API_KEY",), + base_url_env_vars=(f"{env_prefix}_BASE_URL",), + secrets=load_secrets(), + secrets_section_priority=DEFAULT_SECRETS_SECTION_PRIORITY, + default_provider=provider, + ).api_key prompt_default_api_key = ( existing_api_key or request.prompt_default_api_key + or env_api_key ) prompt_default_api_key_source = request.prompt_default_api_key_source if reuse_existing_provider and existing_api_key: diff --git a/switchyard/cli/configure_request.py b/switchyard/cli/configure_request.py index 435dd276..70495dde 100644 --- a/switchyard/cli/configure_request.py +++ b/switchyard/cli/configure_request.py @@ -15,8 +15,6 @@ from dataclasses import dataclass, fields from typing import Literal -from switchyard.cli.config.user_config import DEFAULT_PROVIDER - # Which defaults `configure` writes. Matches the CLI's --target choices. ConfigureTarget = Literal["all", "provider", "claude", "codex", "openclaw"] @@ -35,7 +33,7 @@ class ConfigureRequest: # Which provider and scope to configure. json: bool = False target: ConfigureTarget = "all" - provider: str = DEFAULT_PROVIDER + provider: str | None = None base_url: str | None = None api_key: str | None = None diff --git a/switchyard/cli/launchers/launcher_runtime.py b/switchyard/cli/launchers/launcher_runtime.py index 333d424c..0faa25e8 100644 --- a/switchyard/cli/launchers/launcher_runtime.py +++ b/switchyard/cli/launchers/launcher_runtime.py @@ -3,8 +3,6 @@ """Shared proxy/runtime helpers for one-command launchers.""" -from __future__ import annotations - import logging import os import socket @@ -13,20 +11,21 @@ import time import urllib.request from pathlib import Path -from typing import TYPE_CHECKING import uvicorn from switchyard.cli.config.user_config import get_user_config_dir +from switchyard.lib.profiles.deterministic_routing_config import DeterministicRoutingConfig from switchyard.lib.route_table import SwitchyardApp from switchyard.server.switchyard_app import build_switchyard_app -if TYPE_CHECKING: - from switchyard.lib.profiles.deterministic_routing_config import DeterministicRoutingConfig - _debug_file_handler: logging.FileHandler | None = None log = logging.getLogger(__name__) +#: Opener that ignores env proxies — loopback probes to the in-process proxy +#: must never be routed through a configured HTTP_PROXY. +_LOCAL_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + #: System CA bundle paths to try (Debian/Ubuntu, RHEL/CentOS/Fedora). _SYSTEM_CA_BUNDLE_CANDIDATES = ( @@ -75,7 +74,9 @@ def wait_for_proxy_ready(port: int, *, timeout_s: float) -> bool: deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: try: - with urllib.request.urlopen(url, timeout=0.5): + # Loopback: bypass env proxies so a configured HTTP_PROXY can't + # intercept the 127.0.0.1 health probe. + with _LOCAL_OPENER.open(url, timeout=0.5): return True except Exception: time.sleep(0.05) diff --git a/switchyard/cli/launchers/proxy_health_monitor.py b/switchyard/cli/launchers/proxy_health_monitor.py index 92f8cde7..b5a12933 100644 --- a/switchyard/cli/launchers/proxy_health_monitor.py +++ b/switchyard/cli/launchers/proxy_health_monitor.py @@ -3,10 +3,9 @@ """Lightweight health indicator for launcher footers.""" -from __future__ import annotations - import time -import urllib.request + +from switchyard.cli.launchers.launcher_runtime import _LOCAL_OPENER class ProxyHealthMonitor: @@ -31,7 +30,9 @@ def poll(self) -> None: return self._last_check = now try: - with urllib.request.urlopen(self._url, timeout=0.5): + # Loopback: bypass env proxies so a configured HTTP_PROXY can't + # intercept the 127.0.0.1 health probe. + with _LOCAL_OPENER.open(self._url, timeout=0.5): self._healthy = True except Exception: self._healthy = False diff --git a/switchyard/cli/switchyard_cli.py b/switchyard/cli/switchyard_cli.py index 5a2d9e26..2f18b80f 100644 --- a/switchyard/cli/switchyard_cli.py +++ b/switchyard/cli/switchyard_cli.py @@ -812,8 +812,9 @@ def _build_parser() -> argparse.ArgumentParser: ), ) cfg.add_argument( - "--provider", type=str, default=DEFAULT_PROVIDER, - help=f"Provider id to configure (default: {DEFAULT_PROVIDER})", + "--provider", type=str, default=None, + help=f"Provider id to configure (default: the saved default_provider, " + f"or {DEFAULT_PROVIDER} if none is saved)", ) cfg.add_argument( "--base-url", type=str, default=None, @@ -1283,10 +1284,19 @@ def main() -> None: # switchyard --routing-profiles dev.yaml -- launch claude # The '--' is purely visual — argparse doesn't need it. argv = list(sys.argv[1:]) - try: - argv.pop(argv.index("--")) - except ValueError: - pass + # Only strip a '--' that precedes the subcommand token; a '--' after it is + # the harness separator (e.g. `launch claude ... -- --version`) and must + # survive so forwarded args reach the launcher instead of tripping argparse. + # Read the subcommand names off the parser so this can't drift as + # subcommands are added. + subparsers_action = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + subcommands = set(subparsers_action.choices) + sep_idx = argv.index("--") if "--" in argv else len(argv) + cmd_idx = next((i for i, t in enumerate(argv) if t in subcommands), len(argv)) + if sep_idx < cmd_idx: + argv.pop(sep_idx) args = parser.parse_args(argv) if not hasattr(args, "func"): diff --git a/switchyard/server/verify.py b/switchyard/server/verify.py index d82b6e20..fd06bf9f 100644 --- a/switchyard/server/verify.py +++ b/switchyard/server/verify.py @@ -31,8 +31,6 @@ plain terminals, and ``capture_output=True`` subprocess captures. """ -from __future__ import annotations - import asyncio import json import logging @@ -486,7 +484,9 @@ def _proxy_roundtrip(port: int, model: str) -> str: "max_tokens": 2048, } start = time.monotonic() - with httpx.Client(timeout=_ROUNDTRIP_TIMEOUT_S) as client: + # Loopback probe to our own in-process proxy — bypass env proxies so a + # configured HTTP_PROXY doesn't intercept the 127.0.0.1 request. + with httpx.Client(timeout=_ROUNDTRIP_TIMEOUT_S, trust_env=False) as client: resp = client.post(url, json=body) elapsed_ms = (time.monotonic() - start) * 1000.0 if resp.status_code != 200: diff --git a/tests/test_launch_claude.py b/tests/test_launch_claude.py index f9b07fbe..92962250 100644 --- a/tests/test_launch_claude.py +++ b/tests/test_launch_claude.py @@ -140,17 +140,63 @@ def test_configure_parser_builds_a_complete_request() -> None: # A real `switchyard configure` parse must fill every ConfigureRequest field # through the one from_namespace boundary. This is the check the original bug # lacked: disable_skill_distillation is the field that crashed first-run. - from switchyard.cli.config.user_config import DEFAULT_PROVIDER from switchyard.cli.switchyard_cli import _build_parser namespace = _build_parser().parse_args(["configure"]) request = ConfigureRequest.from_namespace(namespace) - assert request.provider == DEFAULT_PROVIDER + # --provider defaults to None so it can't shadow a saved default_provider; + # cmd_configure resolves the effective provider from the saved config. + assert request.provider is None assert request.disable_skill_distillation is False assert request.routing_profiles is None # the global flag is merged in +def test_main_forwards_harness_args_after_separator(monkeypatch, tmp_path) -> None: + """``launch claude ... -- --version`` forwards harness args past the ``--``. + + main() must only strip a ``--`` that occurs before the subcommand token, + so the launcher-side ``--`` separating claude args survives argparse and + the forwarded args reach ``launch_claude`` intact. Before the fix the first + ``--`` was popped unconditionally, so ``--version`` hit argparse as an + unrecognized argument (``SystemExit(2)``). + """ + import sys + + from switchyard.cli import switchyard_cli as cli + + monkeypatch.setenv("SWITCHYARD_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr( + sys, "argv", + [ + "switchyard", "launch", "claude", + "--model", "nvidia/x", "--api-key", "sk-test", + "--", "--version", + ], + ) + monkeypatch.setattr( + "switchyard.cli.launch_command.resolve_launch_connectivity", + lambda args, **_kw: ("sk-test", "https://inference-api.nvidia.com/v1"), + ) + + captured: dict = {} + + def fake_launch(**kwargs): + captured.update(kwargs) + raise SystemExit(0) + + monkeypatch.setattr( + "switchyard.cli.launchers.claude_code_launcher.launch_claude", + fake_launch, + ) + + with pytest.raises(SystemExit) as excinfo: + cli.main() + + assert excinfo.value.code == 0 + assert captured["claude_args"] == ["--version"] + + # --------------------------------------------------------------------------- # _ModelRewriteRequestProcessor # --------------------------------------------------------------------------- diff --git a/tests/test_launcher_proxy_bypass.py b/tests/test_launcher_proxy_bypass.py new file mode 100644 index 00000000..bb553ee8 --- /dev/null +++ b/tests/test_launcher_proxy_bypass.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Loopback readiness probes must ignore env proxies (HTTP_PROXY/NO_PROXY).""" + +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +from switchyard.cli.launchers.launcher_runtime import wait_for_proxy_ready + + +class _HealthHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + +def test_wait_for_proxy_ready_bypasses_env_proxy(monkeypatch): + """A configured HTTP_PROXY must not intercept the 127.0.0.1 health probe.""" + server = HTTPServer(("127.0.0.1", 0), _HealthHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + # Point env proxy at a dead port and remove any NO_PROXY exemption; before + # the fix the loopback probe would route here and fail. + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:9") + monkeypatch.setenv("http_proxy", "http://127.0.0.1:9") + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + try: + assert wait_for_proxy_ready(port, timeout_s=2) is True + finally: + server.shutdown() + server.server_close() diff --git a/tests/test_user_config.py b/tests/test_user_config.py index ec5a57da..0eccabe7 100644 --- a/tests/test_user_config.py +++ b/tests/test_user_config.py @@ -333,6 +333,116 @@ def test_configure_invalid_skill_namespace_reports_user_error( assert "letters, numbers, dot, underscore, and hyphen" in message +def test_configure_list_models_honors_saved_default_provider( + monkeypatch, + tmp_path, +): + """`configure --list-models` uses the saved default_provider, not the + argparse --provider default (which now defaults to None so it can't shadow + the saved value).""" + from switchyard.cli.configure_command import _list_models + from switchyard.cli.switchyard_cli import _build_parser + + monkeypatch.setenv("SWITCHYARD_CONFIG_DIR", str(tmp_path)) + save_user_config( + UserConfig( + default_provider="nvidia", + providers={"nvidia": ProviderConfig(base_url="https://nvidia.test/v1")}, + ), + config_dir=tmp_path, + ) + save_user_credentials( + UserCredentials(api_keys={"nvidia": "nvidia-key"}), + config_dir=tmp_path, + ) + monkeypatch.setattr("switchyard.server.server_util.load_secrets", lambda: {}) + + captured: dict = {} + monkeypatch.setattr( + "switchyard.cli.configure_command.fetch_model_ids", + lambda base_url, api_key: captured.update(base_url=base_url, api_key=api_key) or [], + ) + monkeypatch.setattr( + "switchyard.cli.configure_command.render_models", + lambda model_ids, request: "", + ) + + parser = _build_parser() + assert parser.parse_args(["configure"]).provider is None + args = parser.parse_args(["configure", "--list-models"]) + + _list_models(args) + + assert captured["base_url"] == "https://nvidia.test/v1" + assert captured["api_key"] == "nvidia-key" + + +def test_configure_non_interactive_uses_env_api_key( + monkeypatch, + tmp_path, +): + """Non-interactive `configure` falls back to an env/secrets API key instead + of erroring out when none is passed on the CLI.""" + from switchyard.cli.switchyard_cli import _build_parser, _cmd_configure + + monkeypatch.setenv("SWITCHYARD_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("OPENROUTER_API_KEY", "env-or-key") + monkeypatch.setattr( + "switchyard.cli.command_utils.is_interactive_terminal", + lambda: False, + ) + monkeypatch.setattr( + "switchyard.cli.configure_command.is_interactive_terminal", + lambda: False, + ) + monkeypatch.setattr("switchyard.cli.configure_command.load_secrets", lambda: {}) + + parser = _build_parser() + args = parser.parse_args([ + "configure", + "--no-model-discovery", + "--target", "provider", + ]) + + _cmd_configure(args) + + assert load_user_credentials(tmp_path).api_key("openrouter") == "env-or-key" + + +def test_configure_non_interactive_scopes_env_key_to_selected_provider( + monkeypatch, + tmp_path, +): + """`configure --provider nvidia` must not save an OPENROUTER_API_KEY under + `nvidia`. With no NVIDIA_API_KEY set it requires an explicit --api-key.""" + from switchyard.cli.switchyard_cli import _build_parser, _cmd_configure + + monkeypatch.setenv("SWITCHYARD_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key") + monkeypatch.setattr( + "switchyard.cli.command_utils.is_interactive_terminal", + lambda: False, + ) + monkeypatch.setattr( + "switchyard.cli.configure_command.is_interactive_terminal", + lambda: False, + ) + monkeypatch.setattr("switchyard.cli.configure_command.load_secrets", lambda: {}) + + parser = _build_parser() + args = parser.parse_args([ + "configure", + "--provider", "nvidia", + "--no-model-discovery", + "--target", "provider", + ]) + + with pytest.raises(SystemExit, match="requires an API key"): + _cmd_configure(args) + + assert load_user_credentials(tmp_path).api_key("nvidia") is None + + def test_redacted_snapshot_surfaces_only_route_ids(tmp_path): """The snapshot exposes route ids but never the full bundle (env-var references inside the bundle may resolve to secrets at run time)."""