Skip to content
Closed
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
19 changes: 18 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ Direct Rust bindings for migrated concrete processors/backends are exposed from
| Response component | Plain Python/Rust object | `async process(ctx, response) -> ChatResponse` | Post-process (logging, stats) |
| `TranslationEngine` | `switchyard_rust.translation` | `async translate(ctx, request, response) -> Any` | Convert to client's wire format |

### Multi-call backends

`AdvisorLoopBackend` (`switchyard/lib/backends/advisor_loop_backend.py`) pairs the executor with a
stronger advisor that reviews the executor's first no-tool-call turn once per session (APPROVE
returns it; REDO feeds the advisor's plan back and re-invokes the executor). The trigger is
proxy-side, so it fires even for executors that rarely call tools; advisor text goes into the
first user message, never the newest turn, so the upstream cache prefix stays stable across a
session. It is multi-call — one `call(...)` issues several upstream requests before returning one
`ChatResponse`, so "exactly one `LLMBackend` per chain" holds at the chain level only — and it
does its own stats accounting into the classifier bucket, so it must not be wrapped in
`StatsLlmBackend` (which rejects Python-only backends); the route-bundle builder injects the
accumulator through the constructor instead. Executor and advisor targets dispatch independently
on `LlmTarget.format`; `responses` is rejected at `AdvisorConfig` validation. Compose with a
`type: advisor` route (`switchyard/cli/route_bundle.py`) or an `AdvisorPresets` helper.

## Project Structure

```
Expand All @@ -181,6 +196,8 @@ switchyard/
│ │ ├── openai_llm_backend.py # OpenAiPassthroughBackend
│ │ ├── openai_native_backend.py # OpenAiNativeBackend
│ │ ├── anthropic_native_llm_backend.py # AnthropicNativeBackend
│ │ ├── advisor_loop_backend.py # AdvisorLoopBackend (advisor review gate)
│ │ ├── advisor_config.py # AdvisorConfig (+ advisor_prompts, advisor_presets)
Comment on lines +199 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

List the rehomed advisor files explicitly.

Add separate tree entries for advisor_prompts.py and advisor_presets.py. The project structure is a file map, but the current entry lists only advisor_config.py and hides two modules in a parenthetical.

🤖 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 `@AGENTS.md` around lines 199 - 200, Add separate project-tree entries for
advisor_prompts.py and advisor_presets.py alongside advisor_config.py in the
documented advisor file map, and remove their parenthetical mention from the
advisor_config.py description.

│ │ ├── llm_target.py # LlmTarget, BackendFormat
│ │ ├── multi_llm_backend.py # MultiLlmBackend helpers
│ │ ├── stats_llm_backend.py # StatsLlmBackend
Expand Down Expand Up @@ -242,7 +259,7 @@ and their transitives never appear in downstream vulnerability scans.
```bash
export OPENROUTER_API_KEY="sk-or-..."

# Serve the minimal Python YAML bundle (noop and passthrough only).
# Serve the minimal Python YAML bundle (noop, passthrough, and advisor routes).
switchyard serve --routes examples/route.yaml --port 4000
Comment on lines +262 to 263

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the route-bundle description.

examples/route.yaml keeps advisor-gate commented out at Lines 17-22. This command therefore serves only the passthrough and noop routes, not an advisor route. Change the description or enable the advisor route.

🤖 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 `@AGENTS.md` around lines 262 - 263, Correct the description above the
switchyard serve command to state that examples/route.yaml serves only the
passthrough and noop routes, leaving the commented-out advisor-gate
configuration unchanged.


# Launch against the packaged OpenRouter deployment.
Expand Down
18 changes: 15 additions & 3 deletions benchmark/run-baseline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -881,9 +881,21 @@ if [[ "\${SERVER_ENABLED}" == "1" ]]; then
DOCKER_RUN_ARGS=(
-d --rm
--name "\${SWITCHYARD_DOCKER_CONTAINER}"
--network "\${SWITCHYARD_DOCKER_NETWORK}"
--network-alias "\${SWITCHYARD_DOCKER_SERVICE_NAME}"
-p "127.0.0.1:$(q "${PORT}"):$(q "${PORT}")"
)
if [[ "\${SWITCHYARD_DOCKER_NETWORK_MODE:-bridge}" == "host" ]]; then
# Host networking: for upstreams only routable from the host (VPN /
# corp-internal gateways that Docker bridge networks cannot reach).
# Pair with --harbor-server-url http://<host-ip>:<port> so task
# containers reach the server at the host address.
DOCKER_RUN_ARGS+=(--network host)
Comment on lines +885 to +890

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 | 🟠 Major | 🏗️ Heavy lift

Require a reachable Harbor URL for host networking.

When SWITCHYARD_DOCKER_NETWORK_MODE=host, the server does not join ${SWITCHYARD_DOCKER_NETWORK} and does not publish ${SWITCHYARD_DOCKER_SERVICE_NAME} on the task network. The documented default for --harbor-server-url is the Dockerized Switchyard service. A host-mode run without that option can therefore leave Harbor unable to reach the server.

Fail fast unless --harbor-server-url is supplied, or derive a verified host-reachable URL. Add dry-run coverage for host mode, including the absence of bridge-only arguments.

The network contract is defined by benchmark/prepare_harbor_dataset.py and the usage() text in benchmark/run-baseline.sh.

🤖 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 `@benchmark/run-baseline.sh` around lines 885 - 890, Update the host-network
branch in run-baseline.sh to require an explicitly supplied --harbor-server-url,
or derive and validate a host-reachable URL before proceeding; otherwise fail
fast with a clear error. Preserve the existing --network host behavior, ensure
bridge-only network arguments are omitted, and add dry-run coverage for host
mode and the missing-URL failure using the contract in prepare_harbor_dataset.py
and usage().

else
DOCKER_RUN_ARGS+=(
--network "\${SWITCHYARD_DOCKER_NETWORK}"
--network-alias "\${SWITCHYARD_DOCKER_SERVICE_NAME}"
-p "127.0.0.1:$(q "${PORT}"):$(q "${PORT}")"
)
fi
Comment on lines +885 to +897

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate and document SWITCHYARD_DOCKER_NETWORK_MODE.

The else branch treats every value except exact host as bridge. A typo or unsupported value can silently make a host-only upstream unreachable while the run continues with the wrong topology.

Use an explicit host|bridge case and reject other values. Document the variable and its accepted values in usage(). Add dry-run tests for both modes and invalid input.

The accepted-value contract should be visible in the user-facing usage() block.

🤖 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 `@benchmark/run-baseline.sh` around lines 885 - 897, Replace the binary if/else
around SWITCHYARD_DOCKER_NETWORK_MODE with an explicit host|bridge case and
reject unsupported values before constructing Docker arguments. Document
SWITCHYARD_DOCKER_NETWORK_MODE and its accepted host and bridge values in
usage(), and add dry-run coverage for both valid modes plus invalid input.

DOCKER_RUN_ARGS+=(
-v "\${REPO_ROOT}:\${REPO_ROOT}:ro"
-v "\${RUN_DIR}:\${RUN_DIR}"
)
Expand Down
48 changes: 30 additions & 18 deletions crates/switchyard-components/src/backends/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,24 +80,36 @@ impl AnthropicNativeBackend {
}

fn outbound_body(&self, request: &ChatRequest) -> Result<Value> {
let mut body = match request.request_type() {
ChatRequestType::Anthropic => request.body().clone(),
source => {
self.translation
.translate_request(
request_wire_format(source),
WireFormat::AnthropicMessages,
request.body(),
&self.translation_policy,
)
.map_err(|error| {
SwitchyardError::Backend(format!(
"failed to translate {source:?} request to Anthropic Messages: {error}"
))
})?
.body
}
};
// Native Anthropic requests are a TRUE passthrough: forward the client's
// body verbatim, rewriting only the model id (routing) and merging any
// operator-configured `extra_body`. The strips/normalization in the
// translated branch below exist for OpenAI/Responses -> Anthropic bodies,
// which can carry fields/shapes the Anthropic API rejects. Applying them
// to a real Anthropic client (e.g. Claude Code) silently drops valid
// Anthropic features — `context_management` (context auto-compaction),
// signed thinking blocks, mid-conversation system turns, client tool ids
// — which a passthrough must never do.
if matches!(request.request_type(), ChatRequestType::Anthropic) {
let mut body = request.body().clone();
set_json_model(&mut body, self.target.model.as_str());
merge_target_extra_body(&mut body, self.target.extra_body.as_ref());
return Ok(body);
}
let mut body = self
.translation
.translate_request(
request_wire_format(request.request_type()),
WireFormat::AnthropicMessages,
request.body(),
&self.translation_policy,
)
.map_err(|error| {
SwitchyardError::Backend(format!(
"failed to translate {:?} request to Anthropic Messages: {error}",
request.request_type()
))
})?
.body;
set_json_model(&mut body, self.target.model.as_str());
strip_anthropic_incompatible_fields(&mut body);
normalize_anthropic_body(&mut body);
Expand Down
108 changes: 66 additions & 42 deletions crates/switchyard-components/tests/adversarial_native_backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,9 @@ fn anthropic_backend_is_anthropic_only() -> Result<()> {
Ok(())
}

// Non-streaming Anthropic calls should strip incompatible fields and stamp context.
// Non-streaming native Anthropic calls forward the body verbatim and stamp context.
#[tokio::test]
async fn anthropic_non_streaming_strips_incompatible_fields_and_records_context() -> Result<()> {
async fn anthropic_native_passthrough_forwards_body_verbatim() -> Result<()> {
let server = OneShotServer::json(
200,
json!({
Expand Down Expand Up @@ -731,16 +731,22 @@ async fn anthropic_non_streaming_strips_incompatible_fields_and_records_context(
);
assert_eq!(request.body["model"], "target-claude");
assert_eq!(request.body["messages"][0]["content"], "hello");
assert!(request.body.get("reasoning_effort").is_none());
assert!(request.body.get("context_management").is_none());
// Native Anthropic is a verbatim passthrough: client fields are preserved,
// not stripped (only the model id is rewritten for routing). Stripping these
// belongs to the translated path, not a real /v1/messages request.
assert_eq!(request.body["reasoning_effort"], "high");
assert_eq!(
request.body["context_management"],
json!({"strategy": "auto"})
);
assert_eq!(request.body["made_up_beta_field"], json!({"kept": true}));
assert_eq!(request.body["extra_body"], json!({"caller": "value"}));
Ok(())
}

// Anthropic-native calls should downgrade Opus-4.8-style system turns for legacy targets.
// Native Anthropic passthrough must NOT relocate message-level system/developer turns.
#[tokio::test]
async fn anthropic_lifts_message_level_system_roles_before_native_call() -> Result<()> {
async fn anthropic_native_passthrough_does_not_lift_system_messages() -> Result<()> {
let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?;
let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?;
let mut ctx = ProxyContext::new();
Expand All @@ -767,28 +773,24 @@ async fn anthropic_lifts_message_level_system_roles_before_native_call() -> Resu
.await?;
let request = server.captured()?;

assert_eq!(request.body["system"], "System rules.\n\nDeveloper rules.");
let messages = request.body["messages"]
.as_array()
.ok_or_else(|| SwitchyardError::Other("messages should be an array".to_string()))?;
let roles = messages
.iter()
.map(|message| {
message
.get("role")
.and_then(Value::as_str)
.unwrap_or("<missing>")
})
.collect::<Vec<_>>();
assert_eq!(roles, vec!["user", "assistant"]);
assert_eq!(messages[0]["content"], "hello");
assert_eq!(messages[1]["content"], "ready");
// Verbatim: no top-level system is synthesized, and the system/developer
// turns stay exactly where the client put them.
assert!(request.body.get("system").is_none());
assert_eq!(
request.body["messages"],
json!([
{"role": "system", "content": "System rules."},
{"role": "user", "content": "hello"},
{"role": "developer", "content": [{"type": "text", "text": "Developer rules."}]},
{"role": "assistant", "content": "ready"}
])
);
Ok(())
}

// Interleaved system turns should preserve encounter order after lifting.
// Interleaved system/developer turns are preserved in place (no lifting) on passthrough.
#[tokio::test]
async fn anthropic_lifts_multiple_interleaved_system_messages_in_order() -> Result<()> {
async fn anthropic_native_passthrough_preserves_interleaved_system_messages() -> Result<()> {
let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?;
let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?;
let mut ctx = ProxyContext::new();
Expand All @@ -813,24 +815,25 @@ async fn anthropic_lifts_multiple_interleaved_system_messages_in_order() -> Resu
.await?;
let request = server.captured()?;

assert_eq!(
request.body["system"],
"Top-level rules.\n\nFirst lifted system.\n\nSecond lifted system.\n\nDeveloper lifted system."
);
// Verbatim: top-level system unchanged, all message-level turns preserved in place.
assert_eq!(request.body["system"], "Top-level rules.");
assert_eq!(
request.body["messages"],
json!([
{"role": "system", "content": "First lifted system."},
{"role": "user", "content": "first user"},
{"role": "system", "content": "Second lifted system."},
{"role": "assistant", "content": "assistant reply"},
{"role": "developer", "content": "Developer lifted system."},
{"role": "user", "content": "second user"}
])
);
Ok(())
}

// Existing structured Anthropic system prompts should keep their shape when lifted text is added.
// Structured system prompt and message-level system (incl. non-text blocks) pass through untouched.
#[tokio::test]
async fn anthropic_lifts_message_level_system_into_existing_system_blocks() -> Result<()> {
async fn anthropic_native_passthrough_preserves_structured_system_and_messages() -> Result<()> {
let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?;
let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?;
let mut ctx = ProxyContext::new();
Expand Down Expand Up @@ -858,16 +861,25 @@ async fn anthropic_lifts_message_level_system_into_existing_system_blocks() -> R
.await?;
let request = server.captured()?;

// Verbatim: structured system kept as-is; the message-level system turn
// (including its image block) is preserved, not downgraded into system.
assert_eq!(
request.body["system"],
json!([
{"type": "text", "text": "Existing system."},
{"type": "text", "text": "Lifted system.\n\nLifted input text."}
])
json!([{"type": "text", "text": "Existing system."}])
);
assert_eq!(
request.body["messages"],
json!([{"role": "user", "content": "hello"}])
json!([
{
"role": "system",
"content": [
{"type": "text", "text": "Lifted system."},
{"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}},
{"type": "input_text", "text": "Lifted input text."}
]
},
{"role": "user", "content": "hello"}
])
);
Ok(())
}
Expand Down Expand Up @@ -898,9 +910,9 @@ async fn anthropic_translates_responses_requests_with_default_max_tokens() -> Re
Ok(())
}

// Invalid Anthropic tool-use IDs should be sanitized consistently with results.
// Native Anthropic passthrough preserves client tool-use IDs verbatim (no sanitization).
#[tokio::test]
async fn anthropic_sanitizes_invalid_tool_use_ids_and_matching_results() -> Result<()> {
async fn anthropic_native_passthrough_preserves_tool_use_ids() -> Result<()> {
let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?;
let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?;
let mut ctx = ProxyContext::new();
Expand Down Expand Up @@ -936,18 +948,20 @@ async fn anthropic_sanitizes_invalid_tool_use_ids_and_matching_results() -> Resu
.await?;
let request = server.captured()?;

// Verbatim: the client's tool-use id is forwarded unchanged (sanitization
// belongs to the translated path; a real Anthropic client sends valid ids).
let tool_use_id = &request.body["messages"][1]["content"][0]["id"];
assert_eq!(tool_use_id, "toolu_01_bad_id");
assert_eq!(tool_use_id, "toolu_01*bad:id");
assert_eq!(
&request.body["messages"][2]["content"][0]["tool_use_id"],
tool_use_id
);
Ok(())
}

// Unsigned synthetic thinking blocks should be removed before Anthropic replay.
// Native Anthropic passthrough preserves thinking blocks verbatim (no stripping).
#[tokio::test]
async fn anthropic_strips_unsigned_thinking_blocks_before_native_call() -> Result<()> {
async fn anthropic_native_passthrough_preserves_thinking_blocks() -> Result<()> {
let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?;
let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?;
let mut ctx = ProxyContext::new();
Expand Down Expand Up @@ -985,15 +999,22 @@ async fn anthropic_strips_unsigned_thinking_blocks_before_native_call() -> Resul
.await?;
let request = server.captured()?;

// Verbatim: thinking blocks (signed or not) are preserved; the upstream API
// decides what to accept. Stripping unsigned blocks belongs to the translated
// path, where they are synthetic translation artifacts.
assert_eq!(
request.body["messages"][0]["content"]
.as_array()
.ok_or_else(|| SwitchyardError::Other("content should be an array".to_string()))?
.len(),
1
2
);
assert_eq!(
request.body["messages"][0]["content"][0]["type"],
"thinking"
);
assert_eq!(
request.body["messages"][0]["content"][1]["type"],
"tool_use"
);
assert_eq!(
Expand All @@ -1004,7 +1025,10 @@ async fn anthropic_strips_unsigned_thinking_blocks_before_native_call() -> Resul
request.body["messages"][1]["content"][0]["thinking"],
"real"
);
assert_eq!(request.body["messages"][2]["content"], "");
assert_eq!(
request.body["messages"][2]["content"],
json!([{"type": "thinking", "thinking": "only synthetic"}])
);
Ok(())
}

Expand Down
10 changes: 10 additions & 0 deletions examples/route.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ routes:

smoke-test:
type: noop

# Executor gated by a stronger advisor model: the advisor reviews the
# executor's first no-tool-call turn (APPROVE lets it stop; REDO sends it
# back with a plan). Tiers accept the same fields as passthrough targets.
# advisor-gate:
# type: advisor
# executor:
# model: moonshotai/kimi-k2.6
# advisor:
# model: anthropic/claude-opus-4.8
8 changes: 7 additions & 1 deletion switchyard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from typing import TYPE_CHECKING, Any

from switchyard.lib.backends import (
AdvisorConfig,
AdvisorLoopBackend,
AdvisorPresets,
AnthropicNativeBackend,
OpenAiNativeBackend,
)
Expand Down Expand Up @@ -121,7 +124,10 @@ def __getattr__(name: str) -> Any:
"BackendFormat",
"RandomRoutingProcessorConfig",
"LlmTarget",
# Deterministic (LLM-classifier) routing usage case
# Advisor review gate (executor gated by a stronger advisor model)
"AdvisorConfig",
"AdvisorLoopBackend",
"AdvisorPresets",
# Translation engine
"TranslationEngine",
# ChatResponse types
Expand Down
11 changes: 11 additions & 0 deletions switchyard/cli/launchers/launcher_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ def route_bundle_strategy_summary(route_bundle: str, default_model: str) -> str:
target = route.get("target")
model = target.get("model") if isinstance(target, _Mapping) else target
return f"passthrough: model={model or first_key}"
if route_type == "advisor":
tiers = {}
for field in ("executor", "advisor"):
tier = route.get(field)
tiers[field] = (
tier.get("model") if isinstance(tier, _Mapping) else tier
)
return (
f"advisor: executor={tiers['executor']}, "
f"advisor={tiers['advisor']}"
)
Comment on lines +135 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Annotate tiers for strict mypy.

tiers = {} at Line 136 has no annotation and no initial values, so strict mypy reports var-annotated.

Also note the rendered summary shows executor=None, advisor=None when a route omits the tier models, because the branch returns before the route: {default_model} fallback.

As per coding guidelines: "Use type hints throughout; code must satisfy strict mypy checking."

🐛 Proposed fix
                 if route_type == "advisor":
-                    tiers = {}
+                    tiers: dict[str, object] = {}
                     for field in ("executor", "advisor"):
                         tier = route.get(field)
                         tiers[field] = (
                             tier.get("model") if isinstance(tier, _Mapping) else tier
                         )
🤖 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 `@switchyard/cli/launchers/launcher_runtime.py` around lines 135 - 145,
Annotate the empty tiers dictionary in the advisor branch of the route-summary
logic with its string-to-optional-string type so strict mypy accepts it. Also
preserve the route’s default_model fallback when executor or advisor lacks a
model, rather than returning a summary containing None; update the tier
resolution in this branch while retaining the existing executor/advisor summary
format.

Source: Coding guidelines

except Exception:
pass
return f"route: {default_model}"
Expand Down
Loading
Loading