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
21 changes: 20 additions & 1 deletion switchyard/cli/configure_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
prompt_default_api_key_source = request.prompt_default_api_key_source
if reuse_existing_provider and existing_api_key:
Expand Down
4 changes: 1 addition & 3 deletions switchyard/cli/configure_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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

Expand Down
15 changes: 8 additions & 7 deletions switchyard/cli/launchers/launcher_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

"""Shared proxy/runtime helpers for one-command launchers."""

from __future__ import annotations

import logging
import os
import socket
Expand All @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions switchyard/cli/launchers/proxy_health_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
22 changes: 16 additions & 6 deletions switchyard/cli/switchyard_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
elyasmnvidian marked this conversation as resolved.
args = parser.parse_args(argv)

if not hasattr(args, "func"):
Expand Down
6 changes: 3 additions & 3 deletions switchyard/server/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
plain terminals, and ``capture_output=True`` subprocess captures.
"""

from __future__ import annotations

import asyncio
import json
import logging
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 48 additions & 2 deletions tests/test_launch_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
39 changes: 39 additions & 0 deletions tests/test_launcher_proxy_bypass.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +30 to +33

@coderabbitai coderabbitai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use a test-owned or dynamically allocated proxy endpoint.

The test assumes 127.0.0.1:9 is unused. If a service is listening there, the pre-fix path may not fail deterministically. Start a local proxy stub that returns an error, or allocate an unused port for the negative proxy case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_launcher_proxy_bypass.py` around lines 30 - 33, Update the proxy
setup in the affected test to use a test-owned proxy stub or dynamically
allocated local port instead of hard-coded 127.0.0.1:9. Ensure the endpoint
reliably returns a connection error for the negative proxy case, and apply the
dynamically selected endpoint to both HTTP_PROXY and http_proxy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test asserts wait_for_proxy_ready returns True, which only happens if the loopback probe ignores the proxy and reaches the health server. Port 9 (discard) won't answer as a working HTTP forward proxy, so if the bypass ever regressed the probe would fail through :9 and the test would go red. What the test checks doesn't depend on :9 being unbound, so I'll keep the fixed port rather than spin up a stub proxy thread.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elyasmnvidian, understood. The test will catch the expected regression as long as port 9 is the normal discard service—or otherwise not an HTTP forward proxy. My concern is limited to the atypical, host-dependent case where another process provides a functioning proxy on that fixed port; using an owned endpoint would remove that dependency. I’ll leave the decision with you.


try:
assert wait_for_proxy_ready(port, timeout_s=2) is True
finally:
server.shutdown()
server.server_close()
Loading
Loading