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
6 changes: 6 additions & 0 deletions crates/libsy-llm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ fn build_multi_format_client(
transport failures, timeouts, HTTP 408/429, and 5xx responses. Buffered body
transport failures are retried; streaming body failures are not replayed after
the response has been returned.
- `ModelConfig::with_responses_reasoning` controls Responses reasoning replay.
Every Responses model defaults to `PreserveEncrypted`: plaintext is removed,
encrypted provider state is retained. Stored reasoning IDs are retained unless
the request sets `store: false`. Switchyard's translated Anthropic reasoning is
removed. Set `Drop` for
a backend that cannot consume provider reasoning. Messages and tool history are retained.
- `HttpBackendConfig::failure_cooldown` skips completion calls to a backend briefly
after an exhausted transient failure. Zero disables it. State is shared per model
within the client. Calls resume when the cooldown expires. Deployment TOML defaults
Expand Down
233 changes: 174 additions & 59 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub struct ModelConfig {
model_name: ModelId,
default_backend: Backend,
other_backends: Option<Vec<Backend>>,
responses_reasoning: crate::ResponsesReasoningPolicy,
}

impl ModelConfig {
Expand All @@ -79,8 +80,16 @@ impl ModelConfig {
model_name: model_name.into(),
default_backend,
other_backends,
responses_reasoning: crate::ResponsesReasoningPolicy::default(),
}
}

/// Sets how Responses reasoning items are replayed to this model.
#[must_use]
pub fn with_responses_reasoning(mut self, policy: crate::ResponsesReasoningPolicy) -> Self {
self.responses_reasoning = policy;
self
}
}

/// A model-bearing provider operation outside the normal completion endpoint.
Expand Down Expand Up @@ -343,6 +352,13 @@ impl TranslatingLlmClient {
}
omit_configured_body_fields(&mut body, backend.omit_body_fields());
merge_extra_body(&mut body, backend.extra_body());
if matches!(backend, Backend::OpenAiResponses(_)) {
self.model_to_config
.get(model)
.map(|config| config.responses_reasoning)
.unwrap_or_default()
.normalize(&mut body);
Comment thread
deepujain marked this conversation as resolved.
}
// 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);
Expand Down Expand Up @@ -1030,25 +1046,20 @@ const CODEX_NAMESPACE_SEPARATOR: &str = "__";
/// Rewrites a Responses body into the shape OpenAI-compatible providers accept.
///
/// Codex extends Responses with namespace containers and namespaced function calls, and
/// OpenAI-compatible providers expect a flat Responses tool namespace. The input also loses
/// reasoning items that the provider cannot read.
/// OpenAI-compatible providers expect a flat Responses tool namespace.
fn sanitize_openai_responses_provider_body(body: &mut Value) {
let Value::Object(object) = body else {
return;
};
let store_off = object.get("store") == Some(&Value::Bool(false));
sanitize_openai_responses_input_for_provider(object.get_mut("input"), store_off);
sanitize_openai_responses_input_for_provider(object.get_mut("input"));
sanitize_openai_responses_tools_for_provider(object.get_mut("tools"));
sanitize_openai_responses_tool_choice_for_provider(object.get_mut("tool_choice"));
}

/// Removes reasoning items that the provider cannot read and flattens namespaced function
/// calls in the Responses `input` array.
fn sanitize_openai_responses_input_for_provider(input: Option<&mut Value>, store_off: bool) {
fn sanitize_openai_responses_input_for_provider(input: Option<&mut Value>) {
let Some(Value::Array(items)) = input else {
return;
};
items.retain(|item| !is_unreadable_reasoning_item(item, store_off));
for item in items {
let Some(object) = item.as_object_mut() else {
continue;
Expand All @@ -1059,28 +1070,6 @@ fn sanitize_openai_responses_input_for_provider(input: Option<&mut Value>, store
}
}

/// Returns true for a reasoning item that an OpenAI provider would reject with 400.
///
/// A router can serve earlier steps of a turn from Claude and a later step from an OpenAI
/// model, and the client replays Claude's reasoning items on every step. Two kinds fail:
///
/// - An item without `encrypted_content` (absent, null, or empty) when the request sets
/// `store: false`. OpenAI treats the item's ID as a reference to stored reasoning and finds
/// nothing. With `store` on, the ID can refer to stored reasoning, so the item is kept.
/// - An item whose `encrypted_content` holds Anthropic thinking that Switchyard packed for a
/// Responses client. OpenAI cannot decrypt it, whatever `store` is set to.
fn is_unreadable_reasoning_item(item: &Value, store_off: bool) -> bool {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return false;
}
match item.get("encrypted_content").and_then(Value::as_str) {
Some(encrypted) if !encrypted.is_empty() => {
switchyard_translation::is_anthropic_thinking_envelope(encrypted)
}
_ => store_off,
}
}

fn sanitize_openai_responses_tools_for_provider(tools: Option<&mut Value>) {
let Some(Value::Array(tools)) = tools else {
return;
Expand Down Expand Up @@ -1606,6 +1595,13 @@ mod tests {
)]
}

fn responses_map_with_drop_policy(base_url: &str) -> Vec<ModelConfig> {
vec![
ModelConfig::new("gpt", Backend::OpenAiResponses(config(base_url)), None)
.with_responses_reasoning(crate::ResponsesReasoningPolicy::Drop),
]
}

#[tokio::test]
async fn model_properties_uses_configured_auth_and_backend_path()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
Expand Down Expand Up @@ -2401,6 +2397,88 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn responses_reasoning_policy_preserves_replay_history()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
let history = json!([
{"type": "message", "role": "user", "content": "continue"},
{"type": "reasoning", "encrypted_content": "opaque", "content": [{"type": "reasoning_text", "text": "private"}]},
{"type": "reasoning", "id": "rs_stored", "status": "completed", "summary": []},
{"type": "reasoning", "encrypted_content": null},
{"type": "reasoning", "encrypted_content": ""},
{"type": "reasoning"},
{"type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "ok"}
]);
for store in [Some(true), Some(false), None] {
for is_drop in [false, true] {
for has_input_override in [false, true] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "resp_1", "object": "response", "model": "gpt",
"status": "completed", "output": [],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
})))
.expect(1)
.mount(&server)
.await;
let base_url = format!("{}/v1", server.uri());
let mut models = if is_drop {
responses_map_with_drop_policy(&base_url)
} else {
responses_map(&base_url)
};
if has_input_override {
let Backend::OpenAiResponses(backend) = &mut models[0].default_backend
else {
return Err("expected Responses backend".into());
};
backend.omit_body_fields.insert("input".to_string());
backend
.extra_body
.insert("input".to_string(), history.clone());
}
let client = TranslatingLlmClient::new(&models)?;
let mut request = json!({"model": "gpt", "input": history});
if let Some(store) = store {
request["store"] = json!(store);
}
client
.call_rewrite_model_raw(
request,
None,
Some(&ModelId::from("gpt")),
WireFormat::OpenAiResponses,
)
.await?;
let requests = server.received_requests().await.ok_or("missing requests")?;
let body: Value = serde_json::from_slice(&requests[0].body)?;
let mut expected = vec![history[0].clone()];
if !is_drop {
let mut encrypted = history[1].clone();
encrypted["content"] = json!([]);
expected.push(encrypted);
if store != Some(false) {
let mut stored = history[2].clone();
stored["content"] = json!([]);
expected.push(stored);
}
}
expected.extend([history[6].clone(), history[7].clone()]);
assert_eq!(
body["input"],
json!(expected),
"store={store:?}, drop={is_drop}, replace={has_input_override}"
);
}
}
}

Ok(())
}

#[tokio::test]
async fn nvidia_anthropic_requests_strip_strict_from_extra_body()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
Expand Down Expand Up @@ -3470,8 +3548,7 @@ mod tests {
.mount(&server)
.await;

let client = TranslatingLlmClient::new(&responses_map(&format!("{}/v1", server.uri())))?;
let claude_reasoning = json!({
let mut claude_reasoning = json!({
"type": "reasoning",
"id": "rs_b99feb9a-763d-4f7f-b6b2-2c0199b47ded",
"summary": [{"type": "summary_text", "text": "Read the files first."}],
Expand Down Expand Up @@ -3508,13 +3585,13 @@ mod tests {
.as_str()
.unwrap_or_default()
));
let gpt_reasoning = json!({
let mut gpt_reasoning = json!({
"type": "reasoning",
"id": "rs_gpt",
"summary": [],
"encrypted_content": "opaque-encrypted-reasoning"
});
let empty_reasoning =
let mut empty_reasoning =
json!({"type": "reasoning", "id": "rs_empty", "summary": [], "encrypted_content": ""});
let user = json!({"type": "message", "role": "user", "content": "fix the build"});
let call = json!({"type": "function_call", "call_id": "call_1", "name": "exec", "arguments": "{}"});
Expand All @@ -3529,16 +3606,29 @@ mod tests {
empty_reasoning
]);

for store in [json!(false), json!(true)] {
let raw = json!({"model": "client-facing", "store": store, "input": input.clone()});
client
.call_rewrite_model_raw(
raw,
None,
Some(&ModelId::from("gpt")),
WireFormat::OpenAiResponses,
)
.await?;
for has_input_override in [false, true] {
let mut models = responses_map(&format!("{}/v1", server.uri()));
if has_input_override {
let Backend::OpenAiResponses(backend) = &mut models[0].default_backend else {
return Err("expected Responses backend".into());
};
backend.omit_body_fields.insert("input".to_string());
backend
.extra_body
.insert("input".to_string(), input.clone());
}
let client = TranslatingLlmClient::new(&models)?;
for store in [json!(false), json!(true)] {
let raw = json!({"model": "client-facing", "store": store, "input": input.clone()});
client
.call_rewrite_model_raw(
raw,
None,
Some(&ModelId::from("gpt")),
WireFormat::OpenAiResponses,
)
.await?;
}
}

let received = server
Expand All @@ -3549,21 +3639,46 @@ mod tests {
.iter()
.map(|request| serde_json::from_slice::<Value>(&request.body))
.collect::<std::result::Result<_, _>>()?;
// With `store: false`, only GPT's own encrypted reasoning survives.
assert_eq!(sent[0]["input"], json!([user, call, output, gpt_reasoning]));
// With `store: true`, IDs can refer to stored reasoning, but OpenAI still cannot
// decrypt the Switchyard item.
assert_eq!(
sent[1]["input"],
json!([
user,
claude_reasoning,
call,
output,
gpt_reasoning,
empty_reasoning
])
);
assert_eq!(sent.len(), 4);
for request in &sent {
assert!(
request["input"]
.as_array()
.ok_or("expected input array")?
.iter()
.all(|item| !item["encrypted_content"]
.as_str()
.is_some_and(switchyard_translation::is_anthropic_thinking_envelope)),
"provider cannot read translated Anthropic reasoning"
);
}
for item in [
&mut claude_reasoning,
&mut gpt_reasoning,
&mut empty_reasoning,
] {
item["content"] = json!([]);
}
for requests in sent.as_chunks::<2>().0 {
// With `store: false`, only GPT's own encrypted reasoning survives.
assert_eq!(
requests[0]["input"],
json!([user, call, output, gpt_reasoning])
);
// With `store: true`, IDs can refer to stored reasoning, but OpenAI still cannot
// decrypt the Switchyard item.
assert_eq!(
requests[1]["input"],
json!([
user,
claude_reasoning,
call,
output,
gpt_reasoning,
empty_reasoning
])
);
}
Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions crates/libsy-llm-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod observation;
mod observed_translation;
mod openai_decisions;
pub mod raw;
mod responses_reasoning;
pub mod run;
mod system_one;

Expand All @@ -38,6 +39,7 @@ pub use error::{LlmClientError, Result};
pub use observation::{ModelCallObservation, RunObservation, RunObserver};
pub use openai_decisions::OpenAiDecisionClient;
pub use raw::RawResponse;
pub use responses_reasoning::ResponsesReasoningPolicy;
pub use run::{ClientRouter, decide, run};
pub use switchyard_translation::RawEventStream;
pub use system_one::SystemOneClient;
Expand Down
Loading
Loading