Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Per-target `reasoning_effort`** — a target can force the reasoning effort
of every request it serves, replacing the caller's value (`reasoning.effort`
on the Responses wire, `reasoning_effort` on Chat Completions), so a strong
tier can run at `max` behind a client that sends `high`. `extra_body` only
fills absent keys and could not do this. Rejected on Anthropic clients.
- **Raw Responses stream trace** — an opt-in trace of every upstream Responses
event as received, under `RUST_LOG=switchyard_translation::responses::raw=trace`,
for diagnosing provider-specific event shapes. (#646)
Expand Down
11 changes: 11 additions & 0 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ pub struct HttpBackendConfig {
pub extra_headers: BTreeMap<String, String>,
/// Default top-level request fields, applied only when the request omits the key.
pub extra_body: BTreeMap<String, Value>,
/// Reasoning effort forced on every request to this backend, replacing whatever the caller
/// sent. Responses carries it as `reasoning.effort`, Chat Completions as `reasoning_effort`;
/// Anthropic has no equivalent and rejects the setting at configuration time.
pub reasoning_effort: Option<String>,
/// Additional attempts after the initial upstream request.
pub max_retries: u32,
}
Expand All @@ -66,6 +70,7 @@ impl fmt::Debug for HttpBackendConfig {
.field("forward_auth", &self.forward_auth)
.field("extra_header_names", &self.extra_headers.keys())
.field("extra_body_keys", &self.extra_body.keys())
.field("reasoning_effort", &self.reasoning_effort)
.field("max_retries", &self.max_retries)
.finish()
}
Expand Down Expand Up @@ -250,6 +255,11 @@ impl Backend {
&self.config().extra_body
}

/// Reasoning effort forced on outbound requests, if the target configures one.
pub fn reasoning_effort(&self) -> Option<&str> {
self.config().reasoning_effort.as_deref()
}

/// Additional attempts allowed after the initial request.
pub fn max_retries(&self) -> u32 {
self.config().max_retries
Expand Down Expand Up @@ -345,6 +355,7 @@ mod tests {
forward_auth: false,
extra_headers: BTreeMap::new(),
extra_body: BTreeMap::new(),
reasoning_effort: None,
max_retries: 0,
}
}
Expand Down
141 changes: 141 additions & 0 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ impl TranslatingLlmClient {
strip_unsigned_thinking_blocks(&mut body);
}
merge_extra_body(&mut body, backend.extra_body());
// After the merge on purpose: the effort override must win over both the caller's
// value and any `reasoning` default a target set through `extra_body`.
apply_reasoning_effort(&mut body, backend);
if matches!(backend, Backend::Anthropic(_)) {
enable_anthropic_prompt_caching(&mut body);
}
Expand Down Expand Up @@ -933,6 +936,44 @@ fn is_unsigned_thinking_block(block: &Value) -> bool {
)
}

// Forces the target's configured reasoning effort onto the outbound body, replacing the
// caller's value. Unlike `extra_body`, this is an override: a route that sends one model at a
// higher effort than the client asked for is the point of the setting.
fn apply_reasoning_effort(body: &mut Value, backend: &Backend) {
let Some(effort) = backend.reasoning_effort() else {
return;
};
let Value::Object(object) = body else {
return;
};
match backend {
Backend::OpenAiResponses(_) => {
// Responses nests effort under `reasoning` next to fields the caller may have set
// (`summary`, for example), so only the `effort` key is replaced. A `reasoning`
// value that is not an object is malformed and is replaced whole.
let reasoning = object
.entry("reasoning".to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !reasoning.is_object() {
*reasoning = Value::Object(serde_json::Map::new());
}
if let Value::Object(reasoning) = reasoning {
reasoning.insert("effort".to_string(), Value::String(effort.to_string()));
}
}
Backend::OpenAiChat(_) => {
// Chat Completions takes effort as a top-level field.
object.insert(
"reasoning_effort".to_string(),
Value::String(effort.to_string()),
);
}
// Anthropic has no effort field (thinking is a token budget); the runner rejects the
// setting on Anthropic clients at load time, so this arm is unreachable in practice.
Backend::Anthropic(_) => {}
}
}

// Applies target defaults without overriding fields supplied by the caller.
fn merge_extra_body(body: &mut Value, extra_body: &BTreeMap<String, Value>) {
let Value::Object(object) = body else {
Expand Down Expand Up @@ -1051,6 +1092,7 @@ mod tests {
forward_auth: false,
extra_headers: BTreeMap::new(),
extra_body: BTreeMap::new(),
reasoning_effort: None,
max_retries: 0,
}
}
Expand Down Expand Up @@ -1088,6 +1130,22 @@ mod tests {
vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
}

fn chat_map_with_effort(base_url: &str, effort: &str) -> Vec<ModelConfig> {
let mut backend = config(base_url);
backend.reasoning_effort = Some(effort.to_string());
vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
}

fn responses_map_with_effort(base_url: &str, effort: &str) -> Vec<ModelConfig> {
let mut backend = config(base_url);
backend.reasoning_effort = Some(effort.to_string());
vec![ModelConfig::new(
"gpt",
Backend::OpenAiResponses(backend),
None,
)]
}

fn anthropic_map(base_url: &str) -> Vec<ModelConfig> {
vec![ModelConfig::new(
"claude",
Expand Down Expand Up @@ -1541,6 +1599,89 @@ mod tests {
Ok(())
}

/// A configured reasoning effort replaces the caller's value on both OpenAI wire formats,
/// which `extra_body` (defaults only) cannot do.
#[tokio::test]
async fn reasoning_effort_override_replaces_the_callers_effort()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(wiremock::matchers::body_partial_json(json!({
"model": "gpt",
"reasoning_effort": "max"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "1",
"model": "gpt",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"usage": {}
})))
.mount(&server)
.await;
let client = TranslatingLlmClient::new(&chat_map_with_effort(
&format!("{}/v1", server.uri()),
"max",
))?;
client
.call_rewrite_model_raw(
json!({
"model": "client-facing",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "high"
}),
None,
Some(&ModelId::from("gpt")),
WireFormat::OpenAiChat,
)
.await?;

let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(wiremock::matchers::body_partial_json(json!({
"model": "gpt",
"reasoning": {"effort": "max", "summary": "auto"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "resp_1",
"object": "response",
"model": "gpt",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "ok"}]
}],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
})))
.mount(&server)
.await;
let client = TranslatingLlmClient::new(&responses_map_with_effort(
&format!("{}/v1", server.uri()),
"max",
))?;
client
.call_rewrite_model_raw(
json!({
"model": "client-facing",
"input": [{"role": "user", "content": "hi"}],
"reasoning": {"effort": "high", "summary": "auto"}
}),
None,
Some(&ModelId::from("gpt")),
WireFormat::OpenAiResponses,
)
.await?;
Ok(())
}

#[tokio::test]
async fn extra_body_adds_defaults_without_overriding_the_request()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
Expand Down
2 changes: 2 additions & 0 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ mod tests {
forward_auth: false,
extra_headers: BTreeMap::new(),
extra_body: BTreeMap::new(),
reasoning_effort: None,
max_retries: 2,
})
};
Expand Down Expand Up @@ -985,6 +986,7 @@ mod tests {
forward_auth: false,
extra_headers: BTreeMap::new(),
extra_body: BTreeMap::new(),
reasoning_effort: None,
max_retries: 0,
})
};
Expand Down
Loading
Loading