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
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ 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

`AdvisorProfileConfig` (`switchyard/lib/profiles/advisor.py`) dispatches on `AdvisorConfig.strategy`
to one of two backends. `AdvisorToolCallBackend` (`tool_call`, default) offers the executor a
proxy-intercepted `advisor` tool; `AdvisorLoopBackend` (`review_gate`) injects no tool and instead
reviews the executor's first no-tool-call turn, because front-loaded advice suppressed the
executor's own test-and-iterate loop — its trigger is proxy-side, so prefer it for executors that
rarely call tools. Advisor text goes into the system prompt and the first user message, never the
newest turn, so the upstream cache prefix stays stable across a session. Both are multi-call — one
`call(...)` issues several upstream requests before returning one `ChatResponse`, so "exactly one
per chain" holds at the chain level only — and both do their own stats accounting into the
classifier bucket, so they must not be wrapped in `StatsLlmBackend`, which rejects Python-only
backends; `with_runtime_components` attaches the accumulator 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 @@ -182,6 +199,8 @@ switchyard/
│ │ ├── openai_llm_backend.py # OpenAiPassthroughBackend
│ │ ├── openai_native_backend.py # OpenAiNativeBackend
│ │ ├── anthropic_native_llm_backend.py # AnthropicNativeBackend
│ │ ├── advisor_tool_call_backend.py # AdvisorToolCallBackend (advisor tool_call strategy)
│ │ ├── advisor_loop_backend.py # AdvisorLoopBackend (advisor review_gate strategy)
│ │ ├── llm_target.py # LlmTarget, BackendFormat
│ │ ├── multi_llm_backend.py # MultiLlmBackend helpers
│ │ ├── stats_llm_backend.py # StatsLlmBackend
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)
else
DOCKER_RUN_ARGS+=(
--network "\${SWITCHYARD_DOCKER_NETWORK}"
--network-alias "\${SWITCHYARD_DOCKER_SERVICE_NAME}"
-p "127.0.0.1:$(q "${PORT}"):$(q "${PORT}")"
)
fi
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
7 changes: 7 additions & 0 deletions switchyard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
from switchyard.lib.processors.rl_logging_request_processor import RlLoggingRequestProcessor
from switchyard.lib.processors.rl_logging_response_processor import RlLoggingResponseProcessor
from switchyard.lib.profiles import (
AdvisorConfig,
AdvisorPresets,
AdvisorProfileConfig,
ClassifierConfig,
ContextAwareProfile,
DeterministicRoutingConfig,
Expand Down Expand Up @@ -136,6 +139,10 @@ def __getattr__(name: str) -> Any:
# Chain infrastructure
"Switchyard",
"LLMBackend",
# Advisor (executor consults a stronger advisor model)
"AdvisorConfig",
"AdvisorPresets",
"AdvisorProfileConfig",
"StageRouterConfig",
"StageRouterProfileConfig",
"ClassifierConfig",
Expand Down
Loading
Loading