diff --git a/.github/workflows/getting-started.yml b/.github/workflows/getting-started.yml index be8fb016..748929ec 100644 --- a/.github/workflows/getting-started.yml +++ b/.github/workflows/getting-started.yml @@ -51,15 +51,15 @@ jobs: - name: Exercise the guide # Single invocation: pytest picks up tests/getting_started/ (CLI, YAML, # serve lifecycle) AND --markdown-docs execs the Python snippet from - # the guide. The passthrough→noop fixture lives in + # the guide. The local mock-upstream fixture lives in # tests/getting_started/conftest.py so it only loads when collecting # from that subtree. run: uv run pytest tests/getting_started --markdown-docs docs/getting_started.md -v env: - # The server-lifecycle test interpolates ${OPENAI_API_KEY} into a - # noop-backed routes.yaml; the value is never sent upstream. Stub - # so the bundle loader's env-var resolution doesn't fail on a - # clean runner. + # The server-lifecycle test serves a type: model route pointed at a + # local in-process mock OpenAI upstream; these keys are never sent + # upstream. Stub so the bundle loader's env-var resolution doesn't + # fail on a clean runner. OPENAI_API_KEY: sk-test NVIDIA_API_KEY: nvapi-test ANTHROPIC_API_KEY: sk-ant-test diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index a7131216..8b988c57 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -23,12 +23,15 @@ permissions: jobs: proxy-perf: - name: Proxy overhead (No-Op backend) + name: Proxy overhead (local stub backend) runs-on: ubuntu-latest env: PROXY_PORT: 4000 - PERF_MODEL: noop + STUB_PORT: 9000 + # A local zero-latency stub replaces the removed noop route; the proxy + # forwards to it over loopback, which adds a small constant overhead. + PERF_MODEL: mock-model # gpt2 tokenizer is ~500 KB and downloads in < 2 s on Actions runners. # It is only used for dataset-manager token counting; actual inference # token counts come from the server (--use-server-token-count). @@ -52,14 +55,91 @@ jobs: run: uv pip install aiperf # ------------------------------------------------------------------ - # Start the proxy with the No-Op backend + # Start a local zero-latency mock OpenAI upstream. This replaces the + # removed noop route: the proxy serves a real `type: model` chain that + # forwards to this loopback stub, which returns a fixed completion + # instantly. The extra loopback hop adds a small constant overhead. # ------------------------------------------------------------------ - - name: Start switchyard noop proxy + - name: Start local mock OpenAI upstream run: | - cat > bench.yaml <<'YAML' + cat > perf_stub.py <<'PY' + """Zero-latency OpenAI-compatible chat.completions stub (loopback only).""" + import json + import os + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + _COMPLETION = { + "id": "chatcmpl-perf-stub", + "object": "chat.completion", + "created": 1700000000, + "model": "mock-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "4"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, + } + + _CHUNK = { + "id": "chatcmpl-perf-stub", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "mock-model", + "choices": [ + {"index": 0, "delta": {"content": "4"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, + } + + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + if body.get("stream"): + payload = ( + f"data: {json.dumps(_CHUNK)}\n\n".encode() + + b"data: [DONE]\n\n" + ) + content_type = "text/event-stream" + else: + payload = json.dumps(_COMPLETION).encode() + content_type = "application/json" + self.send_response(200) + self.send_header("content-type", content_type) + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args): + return None + + + port = int(os.environ["STUB_PORT"]) + ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever() + PY + python3 perf_stub.py & + echo "STUB_PID=$!" >> "$GITHUB_ENV" + + # ------------------------------------------------------------------ + # Start the proxy: a `type: model` route pointed at the local stub. + # ------------------------------------------------------------------ + - name: Start switchyard proxy + run: | + cat > bench.yaml <> "$GITHUB_ENV" @@ -128,11 +208,13 @@ jobs: --output-artifact-dir perf-results/streaming # ------------------------------------------------------------------ - # Stop proxy + # Stop proxy and local stub # ------------------------------------------------------------------ - - name: Stop proxy + - name: Stop proxy and stub if: always() - run: kill "$PROXY_PID" || true + run: | + kill "$PROXY_PID" || true + kill "$STUB_PID" || true # ------------------------------------------------------------------ # Upload results as a build artifact for comparison over time diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index e21912be..b0bb4a7f 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -51,8 +51,8 @@ jobs: - name: Exercise the README # Single invocation: pytest picks up tests/readme/ (YAML schema + CLI # checks) AND --markdown-docs execs the Python snippet from the README. - # The passthrough→noop fixture lives in tests/readme/conftest.py so it - # only loads when collecting from that subtree. + # The local mock-upstream fixture lives in tests/readme/conftest.py so + # it only loads when collecting from that subtree. run: uv run pytest tests/readme --markdown-docs README.md -v env: # The YAML examples reference ${NVIDIA_API_KEY} etc.; stub so the diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9d04b4..56661edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). KV-cache-aware routing with request failover) or an external load balancer such as [Traefik](https://doc.traefik.io/traefik/reference/routing-configuration/http/load-balancing/service/) or HAProxy. +- **Public `type: noop` and `type: passthrough` route types** — removed from + route bundles. Use `type: model` to register a single explicit model target. + Catalog auto-discovery via a bare `type: passthrough` route is gone; there is + no `type: model` equivalent, so list the model ids you want as explicit + `type: model` routes. ## [0.1.0] — Initial release diff --git a/docs/cli_reference.md b/docs/cli_reference.md index 618942b2..bc4df727 100644 --- a/docs/cli_reference.md +++ b/docs/cli_reference.md @@ -67,7 +67,7 @@ on the launchers; `serve` uses `--routing-profiles`: | Flag | Purpose | |---|---| | `--model ID` | Single-model passthrough. Every request is rewritten to `model=ID` and forwarded to `--base-url`. | -| `--routing-profiles PATH` | Path to a routing-profile YAML bundle. Each entry under `routes:` builds its own chain. Public route types are `model`, `passthrough`, `random_routing`, `stage_router`, `escalation_router`, and `deterministic`. Falls back to the path persisted by `switchyard --routing-profiles PATH -- configure` when omitted. | +| `--routing-profiles PATH` | Path to a routing-profile YAML bundle. Each entry under `routes:` builds its own chain. Public route types are `model`, `random_routing`, `stage_router`, `escalation_router`, and `deterministic`. Falls back to the path persisted by `switchyard --routing-profiles PATH -- configure` when omitted. | On the launchers, the two flags are mutually exclusive: pass one or the other, not both. diff --git a/docs/core_concepts.md b/docs/core_concepts.md index 739c70a7..3e452ab8 100644 --- a/docs/core_concepts.md +++ b/docs/core_concepts.md @@ -37,7 +37,8 @@ has a complete runnable bundle. Every route name is registered as a model ID and listed on `GET /v1/models`. Clients select a route by putting that name in the request's `model` field. -Some route types also discover or register direct upstream model IDs. +Some route types also discover a tier's upstream catalog from its +`GET /v1/models` and register those model IDs directly. ## Tiers and routing strategies @@ -46,7 +47,7 @@ is more capable and more expensive, and a weak target that is cheaper and faster. A tier is a role assigned inside a route, not a fixed property of a model. -A route's `type` sets the strategy. `model` and `passthrough` call one target. +A route's `type` sets the strategy. `model` calls one target. `random_routing` splits traffic on a fixed probability. `deterministic` asks a classifier model to pick a tier. `stage_router` uses agent-progress signals, with an optional classifier fallback. `escalation_router` starts on the weak diff --git a/docs/operations/context_window.md b/docs/operations/context_window.md index 1b9e50c5..a6504e15 100644 --- a/docs/operations/context_window.md +++ b/docs/operations/context_window.md @@ -8,7 +8,7 @@ format. Any multi-target route (stage-router, random_routing, or deterministic) supports this. Set `fallback_target_on_evict` on the route. Single-target routes -(`type: passthrough`, `type: model`) have no alternative target, so the original +(`type: model`) have no alternative target, so the original overflow propagates unchanged. ## Configuration diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index 75dfcdb4..96b8d4cb 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -56,14 +56,13 @@ The examples use model IDs from the [OpenRouter model catalog](https://openrouter.ai/api/v1/models). Select IDs available to your account before deploying; catalog availability can change. -## Model and passthrough routes +## Model routes - `type: model` registers one explicit model alias without model discovery. -- `type: passthrough` queries the upstream model catalog and registers the - discovered models. -Both create direct, single-target chains. Use a routing policy when requests -must be split or classified across targets. +This creates a direct, single-target chain. Use a routing policy when requests +must be split or classified across targets. There is no catalog auto-discovery, +so to expose several upstream models, add one `type: model` route per model id. ## Self-hosted targets @@ -133,7 +132,7 @@ Claude Code lineage signals in either direction: `false` keeps the request on normal routing even when delegated-work headers are present, and `true` marks a request as a sub-agent even when no harness headers appear. -The key applies to `model`, `passthrough`, `deterministic`, `escalation_router`, +The key applies to `model`, `deterministic`, `escalation_router`, and `stage_router` routes. `random_routing` expands into its table entries on a separate path and does not consume it. diff --git a/docs/routing_algorithms/stage_router_routing.md b/docs/routing_algorithms/stage_router_routing.md index be8b6dde..589e9cff 100644 --- a/docs/routing_algorithms/stage_router_routing.md +++ b/docs/routing_algorithms/stage_router_routing.md @@ -280,7 +280,7 @@ curl -s http://localhost:4000/v1/stats > routing_stats_final.json ## When *not* to use stage-router -- **Single-model deployments.** Use a `model` or `passthrough` route instead. +- **Single-model deployments.** Use a `model` route instead. - **Probabilistic A/B splits.** Use [Random Routing](random_routing.md) (`type: random_routing`). The stage-router's signals are wasted on a fixed traffic ratio. diff --git a/examples/route.yaml b/examples/route.yaml index 9575ab5e..df468ce4 100644 --- a/examples/route.yaml +++ b/examples/route.yaml @@ -8,9 +8,6 @@ routes: type: model target: moonshotai/kimi-k2.6 - openrouter-catalog: # `type: passthrough` — auto-discovers available models via GET /v1/models - type: passthrough - random-router: # `type: random_routing` — weighted coin type: random_routing strong: anthropic/claude-opus-4.7 @@ -18,9 +15,6 @@ routes: strong_probability: 0.3 fallback_target_on_evict: strong - bench: # `type: noop` — for benchmarking - type: noop - llm-classifier: # `type: deterministic` — LLM-as-classifier type: deterministic profile: coding_agent # general | coding_agent | openclaw diff --git a/switchyard/__init__.py b/switchyard/__init__.py index 5fc93f89..c64530ef 100644 --- a/switchyard/__init__.py +++ b/switchyard/__init__.py @@ -52,8 +52,6 @@ DeterministicRoutingProfileConfig, EscalationRouterConfig, EscalationRouterProfileConfig, - NoopProfile, - NoopProfileConfig, PassthroughProfileConfig, Profile, ProfileConfig, @@ -138,8 +136,6 @@ def __getattr__(name: str) -> Any: # Chain infrastructure "Switchyard", "LLMBackend", - "NoopProfile", - "NoopProfileConfig", "StageRouterConfig", "StageRouterProfileConfig", "ClassifierConfig", diff --git a/switchyard/cli/launchers/launcher_runtime.py b/switchyard/cli/launchers/launcher_runtime.py index 6d862bc4..c4036741 100644 --- a/switchyard/cli/launchers/launcher_runtime.py +++ b/switchyard/cli/launchers/launcher_runtime.py @@ -274,7 +274,7 @@ def _classifier_part(r: _Mapping) -> str: # type: ignore[type-arg] if route_type == "random_routing": p = r.get("strong_probability", "") return f"random-routing: strong={_model(r.get('strong'))}, weak={_model(r.get('weak'))}, p_strong={p}" - if route_type in ("model", "passthrough"): + if route_type == "model": target = r.get("model") or r.get("target") or route_key return f"passthrough → {target}" return f"{route_type}: {route_key}" diff --git a/switchyard/cli/route_bundle.py b/switchyard/cli/route_bundle.py index a465a390..9e9b454a 100644 --- a/switchyard/cli/route_bundle.py +++ b/switchyard/cli/route_bundle.py @@ -180,18 +180,6 @@ def llm_target_to_route_dict(target: LlmTarget) -> dict[str, Any]: "preset", "fallback_target_on_evict", }) -_PASSTHROUGH_SETTING_KEYS = frozenset({ - "api_key", - "base_url", - "timeout", - "timeout_secs", -}) -_PASSTHROUGH_ROUTE_KEYS = ( - _ROUTE_METADATA_KEYS - | frozenset({"defaults", "enable_stats"}) - | _PASSTHROUGH_SETTING_KEYS -) -_NOOP_ROUTE_KEYS = _ROUTE_METADATA_KEYS _DETERMINISTIC_ROUTE_KEYS = ( _ROUTE_METADATA_KEYS | _TARGET_DEFAULT_ROUTE_KEYS @@ -290,8 +278,6 @@ def llm_target_to_route_dict(target: LlmTarget) -> dict[str, Any]: _ROUTE_KEYS_BY_TYPE: Mapping[str, frozenset[str]] = { "model": _MODEL_ROUTE_KEYS, "random_routing": _RANDOM_ROUTING_ROUTE_KEYS, - "noop": _NOOP_ROUTE_KEYS, - "passthrough": _PASSTHROUGH_ROUTE_KEYS, "deterministic": _DETERMINISTIC_ROUTE_KEYS, "escalation_router": _ESCALATION_ROUTE_KEYS, "stage_router": _STAGE_ROUTER_ROUTE_KEYS, @@ -299,8 +285,6 @@ def llm_target_to_route_dict(target: LlmTarget) -> dict[str, Any]: _DEFAULT_KEYS_BY_TYPE: Mapping[str, frozenset[str]] = { "model": _TARGET_DEFAULT_KEYS, "random_routing": _TARGET_DEFAULT_KEYS, - "passthrough": _PASSTHROUGH_SETTING_KEYS, - "noop": frozenset(), "deterministic": _TARGET_DEFAULT_KEYS, "escalation_router": _TARGET_DEFAULT_KEYS, "stage_router": _TARGET_DEFAULT_KEYS, @@ -365,7 +349,7 @@ def routing_profile_model_ids( Returns each route's YAML key followed by its tier ``model`` fields (``strong`` / ``weak`` for stage_router/deterministic/random_routing, - ``target`` for ``model`` / ``passthrough``). Declaration order, later + ``target`` for ``model``). Declaration order, later duplicates dropped. Returns ``[]`` for a ``None`` or empty bundle. @@ -519,22 +503,6 @@ def build_table_from_bundle( ) continue - # `passthrough` routes hydrate their single tier's catalog into the - # table alongside the configured target model. (`model` routes are - # pure aliases — they register under the route key only, no catalog.) - if route_type == "passthrough": - _merge_discovered_single_tier( - table, - model_id, - route, - route_type=route_type, - target_defaults=route_defaults, - stats=stats, - pre_routing_request_processors=pre_routing_request_processors, - extra_response_processors=extra_response_processors, - ) - continue - # `stage_router` and `deterministic` routes register the routing-policy chain # at the route key AND hydrate each tier's catalog (`strong` + `weak`) # into the table as direct passthroughs — same client-facing @@ -626,69 +594,6 @@ def _merge_random_routing_route( table.set_default_model(default_model) -def _merge_discovered_single_tier( - table: RouteTable, - model_id: str, - route: Mapping[str, object], - route_type: str, - target_defaults: Mapping[str, object], - stats: StatsAccumulator, - pre_routing_request_processors: Sequence[Any] = (), - extra_response_processors: Sequence[Any] = (), -) -> None: - """Expand a ``passthrough`` route with catalog discovery. - - Builds a single ``LlmTarget`` from the route's fields, then goes through - :func:`build_passthrough_table` with the shared - :func:`_default_discovery_fn` — the same path the launcher's per-tier - passthrough registration takes. Follows the unified ordering rule: - - - The YAML route key registered first (aliasing the tier's chain when - the key differs from ``tier.model``). - - The tier's configured model registered next. - - Every catalog entry from the tier's ``GET /v1/models`` after that. - """ - tier = _passthrough_target(model_id, route, target_defaults, route_type) - sub_table = build_passthrough_table( - (tier,), - stats, - enable_stats=_optional_bool(route.get("enable_stats"), default=True), - discovery_fn=_default_discovery_fn, - pre_routing_request_processors=pre_routing_request_processors, - extra_response_processors=extra_response_processors, - ) - was_empty = not table.registered_models() - items = list(sub_table.items()) - alias_registered = False - if model_id != tier.model: - tier_chain, tier_metadata = next( - ((ch, md) for mid, ch, md in items if mid == tier.model), - (None, {}), - ) - if tier_chain is not None: - # YAML key first as an alias to the tier's chain so - # `registered_models()[0]` is always the user-declared route key. - table.register( - model_id, - tier_chain, - metadata={**dict(tier_metadata), "display_name": model_id}, - ) - alias_registered = True - for sub_model, sub_chain, sub_metadata in items: - if sub_model == model_id: - if alias_registered: - continue - # When YAML key == tier.model this entry is the route key itself; - # let it register here so it lands at position 0 of the table. - table.register(sub_model, sub_chain, metadata=sub_metadata) - continue - table.register(sub_model, sub_chain, metadata=sub_metadata) - for warning in sub_table.model_listing_warnings(): - table.add_model_listing_warning(warning) - if was_empty and model_id in table.registered_models(): - table.set_default_model(model_id) - - def _merge_multi_target_discovery( table: RouteTable, model_id: str, @@ -825,12 +730,10 @@ def _build_route_chain( pre_routing_request_processors: Sequence[Any] = (), extra_response_processors: Sequence[Any] = (), ) -> ChainRuntime: - if route_type in ("model", "passthrough"): - # Both kinds resolve to a single-tier passthrough chain — same shape the - # launcher produces via build_passthrough_table's per-tier registration. - # The "passthrough" kind synthesizes a target whose model defaults to the - # route's table key when no explicit target/model is given. - target = _passthrough_target(model_id, route, target_defaults, route_type) + if route_type == "model": + # Resolves to a single-tier passthrough chain — same shape the launcher + # produces via build_passthrough_table's per-tier registration. + target = _passthrough_target(model_id, route, target_defaults) return build_tier_passthrough_switchyard( target, stats, @@ -839,19 +742,6 @@ def _build_route_chain( extra_response_processors=extra_response_processors, ) - if route_type == "noop": - from switchyard.lib.profiles.noop import NoopProfileConfig - - return ProfileSwitchyard( - NoopProfileConfig() - .build() - .with_runtime_components( - stats_accumulator=stats, - pre_request_processors=pre_routing_request_processors, - response_processors=extra_response_processors, - ) - ) - if route_type == "deterministic": return _deterministic_switchyard( model_id, @@ -1245,20 +1135,15 @@ def _passthrough_target( model_id: str, route: Mapping[str, object], target_defaults: Mapping[str, object], - route_type: str, ) -> LlmTarget: - """Resolve the LlmTarget for a ``model`` or ``passthrough`` route. + """Resolve the LlmTarget for a ``model`` route. - ``model`` routes require an explicit ``target`` or ``model`` field. The - ``passthrough`` kind is permissive: if no target is given, the route's - table key becomes the model name and the rest of the target is - populated from defaults plus inline route fields. + A ``model`` route requires an explicit ``target`` or ``model`` field; the + target is built from that plus the route defaults and inline fields. """ target_raw = route.get("target", route.get("model")) if target_raw is None: - if route_type == "model": - raise RouteBundleConfigError(f"route {model_id!r} requires target or model") - target_raw = model_id + raise RouteBundleConfigError(f"route {model_id!r} requires target or model") return coerce_llm_target( _target_mapping(target_raw, target_defaults, default_id=model_id, where="target"), default_id=model_id, @@ -1368,7 +1253,7 @@ def _route_type(model_id: str, route: Mapping[str, object]) -> str: raw_type = route.get("type", route.get("kind")) if raw_type is None: if not route: - return "noop" + raise RouteBundleConfigError(f"route {model_id!r}: missing 'type'") if "strong" in route and "weak" in route: return "random_routing" if "target" in route or "model" in route: @@ -1387,9 +1272,6 @@ def _route_type(model_id: str, route: Mapping[str, object]) -> str: "target": "model", "random": "random_routing", "random_routing": "random_routing", - "noop": "noop", - "no_op": "noop", - "passthrough": "passthrough", "deterministic": "deterministic", "llm_classifier": "deterministic", "llm_classifier_routing": "deterministic", diff --git a/switchyard/cli/switchyard_cli.py b/switchyard/cli/switchyard_cli.py index b0fdaf78..b56856e8 100644 --- a/switchyard/cli/switchyard_cli.py +++ b/switchyard/cli/switchyard_cli.py @@ -933,7 +933,7 @@ def _build_parser() -> argparse.ArgumentParser: " --model X single-model passthrough — every request " "is rewritten to model=X. Falls back to the saved configure default.\n" " --routing-profiles PATH serve a YAML bundle of routes (random, " - "stage_router, passthrough, …); the first declared route " + "stage_router, …); the first declared route " "is the initial model.\n\n" "With neither flag, the saved routing bundle from " "`switchyard configure` is used." @@ -1032,7 +1032,7 @@ def _build_parser() -> argparse.ArgumentParser: " --model X single-model passthrough — every request " "is rewritten to model=X. Falls back to the saved configure default.\n" " --routing-profiles PATH serve a YAML bundle of routes (random, " - "stage_router, passthrough, …); the first declared route " + "stage_router, …); the first declared route " "is the initial model.\n\n" "With neither flag, the saved routing bundle from " "`switchyard configure` is used." @@ -1127,7 +1127,7 @@ def _build_parser() -> argparse.ArgumentParser: " --model X single-model passthrough — every request " "is rewritten to model=X. Falls back to the saved configure default.\n" " --routing-profiles PATH serve a YAML bundle of routes (random, " - "stage_router, passthrough, …); the first declared route " + "stage_router, …); the first declared route " "is the initial model.\n\n" "With neither flag, the saved routing bundle from " "`switchyard configure` is used." diff --git a/switchyard/lib/profiles/__init__.py b/switchyard/lib/profiles/__init__.py index 1b06d983..c4058a56 100644 --- a/switchyard/lib/profiles/__init__.py +++ b/switchyard/lib/profiles/__init__.py @@ -21,7 +21,6 @@ HeaderRoutingDecision, HeaderRoutingProfile, ) -from switchyard.lib.profiles.noop import NoopProfile, NoopProfileConfig from switchyard.lib.profiles.passthrough import PassthroughProfileConfig from switchyard.lib.profiles.protocols import ( ContextAwareProfile, @@ -60,8 +59,6 @@ "HeaderRoutingConfig", "HeaderRoutingDecision", "HeaderRoutingProfile", - "NoopProfile", - "NoopProfileConfig", "PassthroughProfileConfig", "ContextAwareProfile", "Profile", diff --git a/switchyard/lib/profiles/noop.py b/switchyard/lib/profiles/noop.py deleted file mode 100644 index 16580589..00000000 --- a/switchyard/lib/profiles/noop.py +++ /dev/null @@ -1,251 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Profile-backed no-op runtime for proxy overhead benchmarking.""" - -import time -import uuid -from collections.abc import AsyncIterator, Sequence -from dataclasses import dataclass -from typing import Any, cast - -from openai.types import CompletionUsage -from openai.types.chat import ChatCompletion, ChatCompletionChunk -from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from openai.types.chat.chat_completion_chunk import ChoiceDelta -from openai.types.chat.chat_completion_message import ChatCompletionMessage - -from switchyard.lib.chat_response.openai_chat import ResponseStream -from switchyard.lib.profiles.chain import _context_from_input -from switchyard.lib.profiles.table import profile_config -from switchyard.lib.proxy_context import ProxyContext -from switchyard.lib.stats_accumulator import StatsAccumulator -from switchyard_rust.core import ( - ChatRequest, - ChatRequestType, - ChatResponse, - SwitchyardProcessorError, -) -from switchyard_rust.profiles import ProfileInput -from switchyard_rust.translation import TranslationEngine - -_NOOP_SUPPORTED_TYPES = [ChatRequestType.OPENAI_CHAT] -_NOOP_CONTENT = "pong" -_NOOP_MODEL = "noop" - - -def _make_completion(request_id: str, created: int) -> ChatCompletion: - """Build the fixed no-op non-streaming completion.""" - return ChatCompletion( - id=f"chatcmpl-{request_id}", - object="chat.completion", - created=created, - model=_NOOP_MODEL, - choices=[ - Choice( - index=0, - message=ChatCompletionMessage(role="assistant", content=_NOOP_CONTENT), - finish_reason="stop", - ) - ], - usage=CompletionUsage(prompt_tokens=1, completion_tokens=1, total_tokens=2), - ) - - -async def _noop_stream( - request_id: str, - created: int, -) -> AsyncIterator[ChatCompletionChunk]: - """Yield the fixed no-op streaming completion chunks.""" - yield ChatCompletionChunk( - id=f"chatcmpl-{request_id}", - object="chat.completion.chunk", - created=created, - model=_NOOP_MODEL, - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(role="assistant", content=_NOOP_CONTENT), - finish_reason=None, - ) - ], - ) - yield ChatCompletionChunk( - id=f"chatcmpl-{request_id}", - object="chat.completion.chunk", - created=created, - model=_NOOP_MODEL, - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(), - finish_reason="stop", - ) - ], - ) - - -@profile_config("noop") -class NoopProfileConfig: - """Dataclass config for the no-op profile.""" - - def build(self) -> "NoopProfile": - """Build a no-op runtime profile.""" - return NoopProfile() - - -@dataclass(frozen=True, slots=True) -class NoopProcessedRequest: - """Request-side state carried from no-op ``process`` into ``rprocess``.""" - - input: ProfileInput - ctx: ProxyContext - request: ChatRequest - - -class NoopProfile: - """Profile that immediately returns a fixed minimal response. - - No network call is made. The response is always ``"pong"`` for both - streaming and non-streaming requests. Non-OpenAI inbound requests are - translated to OpenAI Chat before the profile reads the streaming flag. - """ - - def __init__( - self, - request_processors: tuple[Any, ...] = (), - response_processors: tuple[Any, ...] = (), - stats_accumulator: StatsAccumulator | None = None, - ) -> None: - """Create a no-op profile with a local translation helper.""" - self._translation = TranslationEngine() - self._request_processors = request_processors - self._response_processors = response_processors - self._stats_accumulator = stats_accumulator - - def iter_components(self) -> list[object]: - """Return lifecycle components in startup order.""" - return [ - *self._request_processors, - self, - *self._response_processors, - ] - - def with_runtime_components( - self, - stats_accumulator: StatsAccumulator | None = None, - enable_stats: bool = True, - pre_request_processors: Sequence[Any] = (), - post_request_processors: Sequence[Any] = (), - response_processors: Sequence[Any] = (), - ) -> "NoopProfile": - """Return a no-op profile with route-table processors attached.""" - from switchyard.lib.processors.stats_request_processor import ( - StatsRequestProcessor, - ) - from switchyard.lib.processors.stats_response_processor_accumulator import ( - StatsResponseProcessor, - ) - - request_chain: list[Any] = [] - response_chain: list[Any] = list(self._response_processors) - if enable_stats: - stats = stats_accumulator or StatsAccumulator() - request_chain.append(StatsRequestProcessor()) - response_chain.append(StatsResponseProcessor(stats)) - - request_chain.extend(pre_request_processors) - request_chain.extend(self._request_processors) - request_chain.extend(post_request_processors) - response_chain.extend(response_processors) - return NoopProfile( - request_processors=tuple(request_chain), - response_processors=tuple(response_chain), - stats_accumulator=stats if enable_stats else None, - ) - - async def process(self, input: ProfileInput) -> NoopProcessedRequest: - """Run request-side processors with a context derived from metadata.""" - return await self.process_with_context(input, _context_from_input(input)) - - async def process_with_context( - self, - input: ProfileInput, - ctx: ProxyContext, - ) -> NoopProcessedRequest: - """Run request-side processors against the caller-supplied context.""" - current: Any = input.request - for processor in self._request_processors: - try: - current = await processor.process(ctx, current) - except Exception as error: - raise SwitchyardProcessorError(str(error)) from error - if not isinstance(current, ChatRequest): - actual = type(current).__name__ - raise SwitchyardProcessorError( - f"Request processor returned {actual}, expected ChatRequest" - ) - return NoopProcessedRequest(input=input, ctx=ctx, request=current) - - async def rprocess( - self, - processed: NoopProcessedRequest, - response: ChatResponse, - ) -> ChatResponse: - """Run response-side processors for the fixed no-op response.""" - current: Any = response - for processor in self._response_processors: - try: - current = await processor.process(processed.ctx, current) - except Exception as error: - raise SwitchyardProcessorError(str(error)) from error - if not isinstance(current, ChatResponse): - actual = type(current).__name__ - raise SwitchyardProcessorError( - f"Response processor returned {actual}, expected ChatResponse" - ) - return cast(ChatResponse, current) - - async def run(self, input: ProfileInput) -> ChatResponse: - """Execute no-op response generation with a derived context.""" - return await self.run_with_context(input, _context_from_input(input)) - - async def run_with_context( - self, - input: ProfileInput, - ctx: ProxyContext, - ) -> ChatResponse: - """Execute no-op response generation with an existing context.""" - processed = await self.process_with_context(input, ctx) - # Stamp ctx.selected_model so the stats response processor attributes - # the response tokens to "noop" instead of "". - ctx.selected_model = _NOOP_MODEL - openai_request = self._translation.request_to_any_of( - processed.request, - _NOOP_SUPPORTED_TYPES, - ) - body: dict[str, object] = dict(openai_request.body) - is_streaming = bool(body.get("stream", False)) - - request_id = uuid.uuid4().hex[:16] - created = int(time.time()) - - if is_streaming: - response = ChatResponse.openai_stream( - ResponseStream(_noop_stream(request_id=request_id, created=created)) - ) - else: - response = ChatResponse.openai_completion( - _make_completion(request_id=request_id, created=created) - ) - # Count the request only after the response is built, so a translation - # failure does not leave a spurious success in /v1/routing/stats. Record - # success before rprocess records tokens, which is the order the stats - # response processor's record_usage_after_success_attribution expects. - if self._stats_accumulator is not None: - await self._stats_accumulator.record_success(_NOOP_MODEL) - return await self.rprocess(processed, response) - - -__all__ = ["NoopProcessedRequest", "NoopProfile", "NoopProfileConfig"] diff --git a/tests/_mock_openai_server.py b/tests/_mock_openai_server.py new file mode 100644 index 00000000..0ef05ce2 --- /dev/null +++ b/tests/_mock_openai_server.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Local in-process mock OpenAI upstream shared by the markdown-docs fixtures. + +The getting-started and README doc tests both redirect a passthrough profile at +a loopback server returning a fixed ``chat.completion`` so the guide snippets +exercise the real backend, openai SDK, and httpx code without touching the +network. This module holds the single copy both fixtures use: the mock server +and the ``redirect_passthrough_to_local_mock`` helper that points +``PassthroughProfileConfig.build`` at it. +""" + +from __future__ import annotations + +import dataclasses +import json +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +#: Fixed OpenAI Chat Completion the mock upstream returns for every request. +_CANNED_COMPLETION: dict[str, object] = { + "id": "chatcmpl-hermetic", + "object": "chat.completion", + "created": 1700000000, + "model": "hermetic-mock", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "4"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, +} + + +class _MockOpenAIServer: + """In-process OpenAI-compatible upstream returning a fixed completion. + + Loopback-only ``ThreadingHTTPServer`` so the doc snippets exercise the real + passthrough backend, openai SDK, and httpx code without touching the network. + """ + + def __init__(self) -> None: + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + def __enter__(self) -> _MockOpenAIServer: + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + content = json.dumps(_CANNED_COMPLETION).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(content))) + self.send_header("connection", "close") + self.end_headers() + self.wfile.write(content) + + def log_message(self, _format: str, *_args: object) -> None: + return None + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self + + def __exit__(self, *_args: object) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=2) + + @property + def base_url(self) -> str: + if self._server is None: + raise RuntimeError("mock server is not running") + host, port = self._server.server_address + return f"http://{host}:{port}/v1" + + +def markdown_docs_active(config: pytest.Config) -> bool: + """Return whether the run enabled the ``--markdown-docs`` flag.""" + try: + return bool(config.getoption("markdowndocs", default=False)) + except (KeyError, ValueError): + return False + + +@contextmanager +def redirect_passthrough_to_local_mock(*, stub_uvicorn: bool = False) -> Iterator[None]: + """Point ``PassthroughProfileConfig.build`` at a local mock OpenAI upstream. + + Starts a loopback :class:`_MockOpenAIServer` and patches the passthrough + profile's ``build`` so a guide snippet's ``switchyard.call`` runs the real + backend, openai SDK, and httpx code fully offline, keeping any other fields + the snippet set on its config. With ``stub_uvicorn=True`` it also replaces + ``uvicorn.run`` with a no-op so a "host as HTTP server" snippet returns + instead of blocking the test session forever. + """ + from switchyard import PassthroughProfileConfig + + real_build = PassthroughProfileConfig.build + monkeypatch = pytest.MonkeyPatch() + with _MockOpenAIServer() as upstream: + + def _hermetic_build(_self: PassthroughProfileConfig) -> object: + return real_build( + dataclasses.replace(_self, api_key="test-key", base_url=upstream.base_url) + ) + + monkeypatch.setattr(PassthroughProfileConfig, "build", _hermetic_build) + if stub_uvicorn: + import uvicorn + + monkeypatch.setattr(uvicorn, "run", lambda *_args, **_kwargs: None) + try: + yield + finally: + monkeypatch.undo() + + +__all__ = [ + "_CANNED_COMPLETION", + "_MockOpenAIServer", + "markdown_docs_active", + "redirect_passthrough_to_local_mock", +] diff --git a/tests/getting_started/conftest.py b/tests/getting_started/conftest.py index 23547eed..7a46aaed 100644 --- a/tests/getting_started/conftest.py +++ b/tests/getting_started/conftest.py @@ -3,7 +3,8 @@ """Markdown-docs fixtures for executing the guide's Python snippets safely. -Aliases passthrough profile builds to no-op profiles (no live backend) and +Points the guide's passthrough profile at a local in-process mock OpenAI +server (loopback, a fixed ``chat.completion``) instead of a live backend, and stubs ``uvicorn.run`` to a no-op — the "host as HTTP server" snippet would otherwise block the test session forever. Both are gated on the ``--markdown-docs`` flag so regular runs are untouched. @@ -12,66 +13,29 @@ from __future__ import annotations from collections.abc import Iterator -from typing import Any import pytest +from tests._mock_openai_server import ( + _MockOpenAIServer, + markdown_docs_active, + redirect_passthrough_to_local_mock, +) -class _NoopProfileFixture: - """No-op profile that preserves optional route-table decoration hooks.""" - def __init__(self) -> None: - """Build the real no-op runtime used by guide snippet execution.""" - from switchyard import NoopProfileConfig - - self._inner = NoopProfileConfig().build() - - def with_runtime_components(self, **_kwargs: Any) -> _NoopProfileFixture: - """Accept route-table runtime components while staying hermetic.""" - return self - - async def process(self, input: Any) -> Any: - """Delegate request-side profile work to the no-op runtime.""" - return await self._inner.process(input) - - async def rprocess(self, processed: Any, response: Any) -> Any: - """Delegate response-side profile work to the no-op runtime.""" - return await self._inner.rprocess(processed, response) - - async def run(self, input: Any) -> Any: - """Run the no-op runtime instead of making a real backend call.""" - return await self._inner.run(input) - - -def _markdown_docs_active(config: pytest.Config) -> bool: - try: - return bool(config.getoption("markdowndocs", default=False)) - except (KeyError, ValueError): - return False +@pytest.fixture +def local_mock_openai_server() -> Iterator[_MockOpenAIServer]: + """A running local mock OpenAI upstream for hermetic serve tests.""" + with _MockOpenAIServer() as server: + yield server @pytest.fixture(autouse=True, scope="session") -def _markdown_docs_passthrough_to_noop( +def _markdown_docs_hermetic_upstream( request: pytest.FixtureRequest, ) -> Iterator[None]: - if not _markdown_docs_active(request.config): + if not markdown_docs_active(request.config): yield return - - import uvicorn - - from switchyard import PassthroughProfileConfig - - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr( - PassthroughProfileConfig, - "build", - lambda _self: _NoopProfileFixture(), - ) - # The "host as HTTP server" snippet ends in a blocking uvicorn.run(); stub it - # so the snippet still exercises build_switchyard_app() without serving. - monkeypatch.setattr(uvicorn, "run", lambda *_args, **_kwargs: None) - try: + with redirect_passthrough_to_local_mock(stub_uvicorn=True): yield - finally: - monkeypatch.undo() diff --git a/tests/getting_started/test_getting_started.py b/tests/getting_started/test_getting_started.py index 0c6b1f0b..2d560846 100644 --- a/tests/getting_started/test_getting_started.py +++ b/tests/getting_started/test_getting_started.py @@ -12,6 +12,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from typing import TYPE_CHECKING import httpx import pytest @@ -25,6 +26,9 @@ from switchyard.cli.route_bundle import RouteBundleConfigError, build_route_bundle_table from switchyard.cli.switchyard_cli import _build_parser +if TYPE_CHECKING: + from tests._mock_openai_server import _MockOpenAIServer + REPO_ROOT = Path(__file__).resolve().parents[2] GUIDE_PATH = REPO_ROOT / "docs" / "getting_started.md" @@ -158,28 +162,31 @@ def test_all_yaml_blocks_in_guide_validate_as_route_bundles( @pytest.fixture -def noop_routes_yaml(tmp_path: Path) -> Path: +def model_routes_yaml(tmp_path: Path, local_mock_openai_server: _MockOpenAIServer) -> Path: # Same shape as the guide's Step 3 YAML (defaults + a single named route), - # but `type: noop` so the lifecycle test runs without an upstream. + # but a `type: model` route pointed at a local in-process mock OpenAI + # server so the lifecycle test serves a real chain fully offline. + base_url = local_mock_openai_server.base_url path = tmp_path / "routes.yaml" path.write_text( - textwrap.dedent("""\ + textwrap.dedent(f"""\ defaults: api_key: dummy - base_url: https://upstream.invalid/v1 + base_url: {base_url} format: openai routes: gpt-4o: - type: noop + type: model + model: gpt-4o """) ) return path -def test_step3_and_step4_serve_lifecycle_with_noop(noop_routes_yaml: Path) -> None: +def test_step3_and_step4_serve_lifecycle_with_local_mock(model_routes_yaml: Path) -> None: port = find_free_port() - with _serve_in_background(noop_routes_yaml, port): + with _serve_in_background(model_routes_yaml, port): health_status, health_body = _http_get(f"http://127.0.0.1:{port}/health") assert health_status == 200, f"GET /health → {health_status}: {health_body!r}" diff --git a/tests/readme/conftest.py b/tests/readme/conftest.py index 3a09e3b2..f7a9763c 100644 --- a/tests/readme/conftest.py +++ b/tests/readme/conftest.py @@ -1,72 +1,34 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Markdown-docs fixture: alias passthrough profile builds to no-op profiles. +"""Markdown-docs fixture: run the README snippet against a local mock upstream. Mirrors ``tests/getting_started/conftest.py``: the README's "Use as a Python library" snippet builds a passthrough profile and calls ``switchyard.call``, -which would otherwise hit a real backend. Gated on the ``--markdown-docs`` flag +which would otherwise hit a real backend. The fixture points that passthrough +profile at a local in-process mock OpenAI server (loopback, a fixed +``chat.completion``) so it runs offline. Gated on the ``--markdown-docs`` flag so regular runs are untouched. """ from __future__ import annotations from collections.abc import Iterator -from typing import Any import pytest - -class _NoopProfileFixture: - """No-op profile that preserves optional route-table decoration hooks.""" - - def __init__(self) -> None: - """Build the real no-op runtime used by README snippet execution.""" - from switchyard import NoopProfileConfig - - self._inner = NoopProfileConfig().build() - - def with_runtime_components(self, **_kwargs: Any) -> _NoopProfileFixture: - """Accept route-table runtime components while staying hermetic.""" - return self - - async def process(self, input: Any) -> Any: - """Delegate request-side profile work to the no-op runtime.""" - return await self._inner.process(input) - - async def rprocess(self, processed: Any, response: Any) -> Any: - """Delegate response-side profile work to the no-op runtime.""" - return await self._inner.rprocess(processed, response) - - async def run(self, input: Any) -> Any: - """Run the no-op runtime instead of making a real backend call.""" - return await self._inner.run(input) - - -def _markdown_docs_active(config: pytest.Config) -> bool: - try: - return bool(config.getoption("markdowndocs", default=False)) - except (KeyError, ValueError): - return False +from tests._mock_openai_server import ( + markdown_docs_active, + redirect_passthrough_to_local_mock, +) @pytest.fixture(autouse=True, scope="session") -def _markdown_docs_passthrough_to_noop( +def _markdown_docs_hermetic_upstream( request: pytest.FixtureRequest, ) -> Iterator[None]: - if not _markdown_docs_active(request.config): + if not markdown_docs_active(request.config): yield return - - from switchyard import PassthroughProfileConfig - - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr( - PassthroughProfileConfig, - "build", - lambda _self: _NoopProfileFixture(), - ) - try: + with redirect_passthrough_to_local_mock(): yield - finally: - monkeypatch.undo() diff --git a/tests/readme/test_readme.py b/tests/readme/test_readme.py index b1686e62..788312b8 100644 --- a/tests/readme/test_readme.py +++ b/tests/readme/test_readme.py @@ -6,7 +6,7 @@ Companion to ``tests/getting_started/``. Three guards: * the "Use as a Python library" snippet executes (via ``--markdown-docs`` + - the passthrough→noop fixture in ``conftest.py``); + the local mock-upstream fixture in ``conftest.py``); * README and routing-guide examples validate against the route-bundle schema; * every CLI subcommand / flag the README names still exists. """ @@ -50,8 +50,8 @@ def _code_blocks(text: str, lang: str) -> list[str]: def test_python_snippet_tripwire(readme_text: str) -> None: - # Guards the shape the conftest's passthrough-profile→noop fixture depends on, plus - # the dict-access fix (call() returns a dict, not an object with `.body`). + # Guards the shape the conftest's passthrough-profile→local-mock fixture depends on, + # plus the dict-access fix (call() returns a dict, not an object with `.body`). assert ( "from switchyard import ChatRequest, PassthroughProfileConfig, ProfileSwitchyard" in readme_text @@ -106,8 +106,9 @@ def test_route_block_validation_rejects_invalid_bundle_defaults() -> None: defaults: unsupported: true routes: - noop: - type: noop + gpt-4o: + type: model + model: gpt-4o ``` """ with pytest.raises(AssertionError, match="unknown key.*defaults: unsupported"): diff --git a/tests/test_launch_claude.py b/tests/test_launch_claude.py index 92962250..6bee53ba 100644 --- a/tests/test_launch_claude.py +++ b/tests/test_launch_claude.py @@ -1133,10 +1133,10 @@ def test_returns_first_yaml_route(self, tmp_path): def test_empty_bundle_raises(self, tmp_path): from switchyard.cli.launch_command import _resolve_initial_from_profiles yaml_path = tmp_path / "empty.yaml" - yaml_path.write_text("routes:\n noop:\n type: noop\n") + yaml_path.write_text("routes:\n direct:\n type: model\n target: some/model\n") assert ( _resolve_initial_from_profiles(target="codex", routing_profiles=str(yaml_path)) - == "noop" + == "direct" ) def test_model_and_profiles_mutually_exclusive(self): diff --git a/tests/test_launch_openclaw_deterministic.py b/tests/test_launch_openclaw_deterministic.py index 8484b552..215208a7 100644 --- a/tests/test_launch_openclaw_deterministic.py +++ b/tests/test_launch_openclaw_deterministic.py @@ -68,7 +68,8 @@ def test_routing_profiles_opts_out_of_classifier( " format: openai\n" "routes:\n" " bench:\n" - " type: noop\n" + " type: model\n" + " target: some/model\n" ) parser = _build_parser() args = parser.parse_args([ diff --git a/tests/test_noop_llm_backend.py b/tests/test_noop_llm_backend.py deleted file mode 100644 index 0a5c4a36..00000000 --- a/tests/test_noop_llm_backend.py +++ /dev/null @@ -1,128 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for the no-op profile.""" - -from switchyard.lib.profiles import NoopProfile, NoopProfileConfig, ProfileSwitchyard -from switchyard.lib.stats_accumulator import StatsAccumulator -from switchyard_rust.core import ChatRequest, ChatResponseType, response_type_matches -from switchyard_rust.profiles import ProfileInput - - -def _openai_request(stream: bool = False) -> ChatRequest: - return ChatRequest.openai_chat( - {"model": "noop", "messages": [{"role": "user", "content": "ping"}], "stream": stream} - ) - - -def _anthropic_request() -> ChatRequest: - return ChatRequest.anthropic( - {"model": "claude-3", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 10} - ) - - -class TestNoOpProfileNonStreaming: - async def test_returns_completion_response(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=False))) - assert response_type_matches(resp, ChatResponseType.OPENAI_COMPLETION) - - async def test_completion_has_pong_content(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=False))) - assert response_type_matches(resp, ChatResponseType.OPENAI_COMPLETION) - assert resp.body["choices"][0]["message"]["content"] == "pong" - - async def test_completion_is_valid_chat_completion(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=False))) - assert response_type_matches(resp, ChatResponseType.OPENAI_COMPLETION) - assert resp.body["object"] == "chat.completion" - assert resp.body["choices"][0]["finish_reason"] == "stop" - - async def test_accepts_anthropic_request(self) -> None: - """Translates non-OpenAI request formats before generating the response.""" - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_anthropic_request())) - assert response_type_matches(resp, ChatResponseType.OPENAI_COMPLETION) - assert resp.body["choices"][0]["message"]["content"] == "pong" - - -class TestNoOpProfileStreaming: - async def test_returns_streaming_response(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=True))) - assert response_type_matches(resp, ChatResponseType.OPENAI_STREAM) - - async def test_stream_yields_chunks(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=True))) - chunks = [chunk async for chunk in resp.stream] - assert len(chunks) >= 1 - - async def test_stream_contains_pong(self) -> None: - profile = NoopProfileConfig().build() - resp = await profile.run(ProfileInput(_openai_request(stream=True))) - contents: list[str] = [] - async for chunk in resp.stream: - for choice in chunk.choices: - if choice.delta.content: - contents.append(choice.delta.content) - assert "".join(contents) == "pong" - - -class TestNoOpRecipe: - def test_noop_recipe_returns_profile_backed_switchyard_adapter(self) -> None: - sy = ProfileSwitchyard(NoopProfileConfig().build()) - assert isinstance(sy, ProfileSwitchyard) - - async def test_noop_recipe_end_to_end(self) -> None: - sy = ProfileSwitchyard(NoopProfileConfig().build()) - # ``sy.call()`` preserves the serving contract while no-op construction - # now goes through the Profile abstraction instead of a backend pipeline. - resp = await sy.call(_openai_request(stream=False)) - assert resp["choices"][0]["message"]["content"] == "pong" - - -class TestNoOpProfile: - def test_config_builds_noop_profile(self) -> None: - profile = NoopProfileConfig().build() - assert isinstance(profile, NoopProfile) - - async def test_profile_run_returns_completion_response(self) -> None: - profile = NoopProfileConfig().build() - - resp = await profile.run(ProfileInput(_openai_request(stream=False))) - - assert response_type_matches(resp, ChatResponseType.OPENAI_COMPLETION) - assert resp.body["choices"][0]["message"]["content"] == "pong" - - async def test_profile_process_and_rprocess_are_explicit_hooks(self) -> None: - profile = NoopProfileConfig().build() - profile_input = ProfileInput(_openai_request(stream=False)) - response = await profile.run(profile_input) - - processed = await profile.process(profile_input) - processed_response = await profile.rprocess(processed, response) - - assert processed.request.model == "noop" - assert processed_response is response - - async def test_noop_call_reports_routing_stats(self) -> None: - """The no-op profile self-reports its call and model to the stats accumulator.""" - stats = StatsAccumulator() - profile = NoopProfileConfig().build().with_runtime_components( - stats_accumulator=stats - ) - - await ProfileSwitchyard(profile).call(_openai_request(stream=False)) - - snap = stats.snapshot_sync() - assert snap["total_requests"] == 1 - assert "noop" in snap["models"] - assert snap["models"]["noop"]["calls"] == 1 - assert "" not in snap["models"] - # Response tokens land on "noop", not "". - assert snap["models"]["noop"]["prompt_tokens"] == 1 - assert snap["models"]["noop"]["completion_tokens"] == 1 - assert snap["models"]["noop"]["total_tokens"] == 2 diff --git a/tests/test_resolve_classifier_prompts.py b/tests/test_resolve_classifier_prompts.py index f5134a15..f65b7288 100644 --- a/tests/test_resolve_classifier_prompts.py +++ b/tests/test_resolve_classifier_prompts.py @@ -107,7 +107,7 @@ def test_non_deterministic_routes_are_skipped(tmp_path: Path) -> None: module = _load_resolver_module() bundle = { "routes": { - "r/pass": {"type": "passthrough"}, + "r/pass": {"type": "model", "target": "some/model"}, "r/rand": {"type": "random_routing"}, }, } diff --git a/tests/test_route_bundle.py b/tests/test_route_bundle.py index 2f736554..3eef241e 100644 --- a/tests/test_route_bundle.py +++ b/tests/test_route_bundle.py @@ -140,7 +140,6 @@ def test_route_bundle_rejects_missing_environment_variable() -> None: }, "strong_probablity", ), - ({"type": "passthrough", "model": "gpt-4o"}, "model"), ({"type": "model", "target": {"modle": "gpt-4o"}}, "modle"), ], ) @@ -152,11 +151,24 @@ def test_route_bundle_rejects_unknown_route_keys( build_route_bundle_table({"routes": {"bad": route}}) -def test_empty_route_mapping_registers_noop() -> None: - table = build_route_bundle_table({"routes": {"noop": {}}}) +@pytest.mark.parametrize("route_type", ["passthrough", "noop"]) +def test_removed_route_types_are_rejected(route_type: str) -> None: + """``type: passthrough`` and ``type: noop`` are no longer valid route types.""" + with pytest.raises(RouteBundleConfigError, match="unsupported route type"): + build_route_bundle_table({"routes": {"r": {"type": route_type}}}) - assert table.registered_models() == ["noop"] - assert table.registered_model_entries()[0]["switchyard"]["profile"] == "noop" + +def test_empty_route_mapping_is_rejected() -> None: + """An empty ``{}`` route no longer defaults to a noop; it is rejected.""" + with pytest.raises(RouteBundleConfigError, match="missing 'type'"): + build_route_bundle_table({"routes": {"r": {}}}) + + +def test_model_route_requires_target() -> None: + """``type: model`` is the only single-upstream route, so it fails closed + when no ``target``/``model`` is given rather than inventing a default.""" + with pytest.raises(RouteBundleConfigError, match="requires target or model"): + build_route_bundle_table({"routes": {"m": {"type": "model"}}}) def test_random_routing_hydrates_tier_and_catalog_models( @@ -241,86 +253,6 @@ def test_model_route_aliases_under_route_key( assert table.default_model() == "configured-route" -def test_passthrough_route_hydrates_catalog( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A ``passthrough`` route hydrates the catalog under the route id. - - The route's YAML key becomes the tier's configured model name (passthrough - routes don't take an explicit target); catalog entries register alongside. - """ - monkeypatch.setattr( - "switchyard.cli.route_bundle.fetch_model_ids", - lambda base_url, api_key: ["catalog/x", "catalog/y"], - ) - - table = build_route_bundle_table({ - "routes": { - "openai-passthrough": { - "type": "passthrough", - "api_key": "k", - "base_url": "https://example/v1", - }, - }, - }) - - assert table.registered_models() == [ - "openai-passthrough", - "catalog/x", - "catalog/y", - ] - assert table.default_model() == "openai-passthrough" - - -def test_route_bundle_keeps_first_passthrough_route_as_default() -> None: - """Later discovered-route merges must not override the advertised default.""" - table = build_route_bundle_table({ - "routes": { - "first-route": { - "type": "passthrough", - "api_key": "k-first", - "base_url": "https://first.example/v1", - }, - "second-route": { - "type": "passthrough", - "api_key": "k-second", - "base_url": "https://second.example/v1", - }, - }, - }) - - assert table.registered_models() == ["first-route", "second-route"] - assert table.default_model() == "first-route" - - -def test_passthrough_route_preserves_warning_when_catalog_fetch_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fail_catalog(_base_url: str, _api_key: str) -> list[str]: - raise RuntimeError("catalog timed out") - - monkeypatch.setattr( - "switchyard.cli.route_bundle.fetch_model_ids", - fail_catalog, - ) - - table = build_route_bundle_table({ - "routes": { - "configured-route": { - "type": "passthrough", - "api_key": "k", - "base_url": "https://primary.example/v1", - }, - }, - }) - - assert table.registered_models() == ["configured-route"] - assert table.default_model() == "configured-route" - assert table.model_listing_warnings() == [ - "Model discovery failed for https://primary.example/v1: catalog timed out" - ] - - def test_random_routing_with_empty_catalog_registers_only_tier_passthroughs() -> None: """When discovery yields an empty catalog, only tier passthroughs register. @@ -362,12 +294,12 @@ def test_route_bundle_threads_extra_processors_through_routes() -> None: response_processor = _NoopResponseProcessor() table = build_route_bundle_table( - {"routes": {"noop": {"type": "noop"}}}, + {"routes": {"direct": {"type": "model", "target": "some/model"}}}, pre_routing_request_processors=[request_processor], extra_response_processors=[response_processor], ) - components = table.lookup_switchyard("noop").iter_components() + components = table.lookup_switchyard("direct").iter_components() assert request_processor in components assert response_processor in components @@ -379,7 +311,7 @@ def test_serve_subcommand_hands_table_to_server( import switchyard.cli.switchyard_cli as cli yaml_path = tmp_path / "routes.yaml" - yaml_path.write_text("routes:\n noop:\n type: noop\n") + yaml_path.write_text("routes:\n direct:\n type: model\n target: some/model\n") captured: dict[str, Any] = {} def _fake_serve( @@ -404,7 +336,7 @@ def _fake_serve( args.func(args) assert isinstance(captured["switchyard"], RouteTable) - assert captured["switchyard"].registered_models() == ["noop"] + assert captured["switchyard"].registered_models() == ["direct"] assert captured["args"].port == 4555 assert captured["inbound_default"] == "both" @@ -500,7 +432,7 @@ def test_main_accepts_routing_profiles_flag( capsys: pytest.CaptureFixture[str], ) -> None: yaml_path = tmp_path / "routes.yaml" - yaml_path.write_text("routes:\n noop:\n type: noop\n") + yaml_path.write_text("routes:\n direct:\n type: model\n target: some/model\n") monkeypatch.setattr( sys, "argv", ["switchyard", "--routing-profiles", str(yaml_path), "serve"] @@ -529,7 +461,9 @@ def test_serve_accepts_saved_route_bundle( from switchyard.cli.config.user_config import UserConfig, save_user_config monkeypatch.setenv("SWITCHYARD_CONFIG_DIR", str(tmp_path)) - save_user_config(UserConfig(routing_profiles={"routes": {"noop": {"type": "noop"}}})) + save_user_config(UserConfig(routing_profiles={"routes": { + "direct": {"type": "model", "target": "some/model"}, + }})) def _fake_serve( args: argparse.Namespace, @@ -560,8 +494,9 @@ def test_serve_subcommand_enables_intake_from_cli_args( yaml_path = tmp_path / "routes.yaml" yaml_path.write_text( "routes:\n" - " noop:\n" - " type: noop\n" + " direct:\n" + " type: model\n" + " target: some/model\n" ) captured: dict[str, Any] = {} @@ -589,7 +524,7 @@ def _fake_serve( ]) args.func(args) - components = captured["switchyard"].lookup_switchyard("noop").iter_components() + components = captured["switchyard"].lookup_switchyard("direct").iter_components() assert any(isinstance(component, IntakeRequestProcessor) for component in components) assert any(isinstance(component, IntakeResponseProcessor) for component in components) diff --git a/tests/test_routing_banner.py b/tests/test_routing_banner.py index 390db933..0a6d96d3 100644 --- a/tests/test_routing_banner.py +++ b/tests/test_routing_banner.py @@ -35,11 +35,11 @@ model: clf-model/v1 """) -_PASSTHROUGH_YAML = textwrap.dedent("""\ +_MODEL_ROUTE_YAML = textwrap.dedent("""\ routes: my_route: - type: passthrough - model: some/model + type: model + target: some/model """) @@ -55,10 +55,10 @@ def test_routing_profiles_stage_router(self, tmp_path): result = routing_profiles_strategy_summary(str(p), "my_route") assert result == "stage_router: strong=strong-model/v1, weak=weak-model/v1, llm-classifier=clf-model/v1, confidence_threshold=0.7" - def test_routing_profiles_passthrough_type(self, tmp_path): - """routing_profiles_strategy_summary describes a passthrough-type route.""" + def test_routing_profiles_model_type(self, tmp_path): + """routing_profiles_strategy_summary renders a model-type route as passthrough.""" p = tmp_path / "profiles.yaml" - p.write_text(_PASSTHROUGH_YAML) + p.write_text(_MODEL_ROUTE_YAML) result = routing_profiles_strategy_summary(str(p), "my_route") assert result == "passthrough → some/model" diff --git a/tests/test_run_manifest.py b/tests/test_run_manifest.py index a94ec9dc..e920ac26 100644 --- a/tests/test_run_manifest.py +++ b/tests/test_run_manifest.py @@ -122,7 +122,9 @@ def test_cli_write_records_routing_profile_digest(tmp_path: Path) -> None: out = tmp_path / "run_manifest.json" run_dir = tmp_path / "run" profile = tmp_path / "routes.yaml" - profile.write_text("routes:\n tb-lite-random-routing:\n type: noop\n") + profile.write_text( + "routes:\n tb-lite-random-routing:\n type: model\n target: some/model\n" + ) dataset = tmp_path / "dataset" dataset.mkdir() diff --git a/tests/test_user_config.py b/tests/test_user_config.py index 4c19cf83..0357312d 100644 --- a/tests/test_user_config.py +++ b/tests/test_user_config.py @@ -570,7 +570,7 @@ def test_redacted_snapshot_surfaces_only_route_ids(tmp_path): save_user_config( UserConfig(routing_profiles={"routes": { "alpha/model": {"type": "model"}, - "beta/model": {"type": "passthrough"}, + "beta/model": {"type": "model", "target": "other/model"}, }}), config_dir=tmp_path, )