diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 0265faf99..faec5317c 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -14,6 +14,7 @@ use futures_util::{StreamExt, stream}; use http::StatusCode; use reqwest::RequestBuilder; use reqwest::header::{HeaderMap, RETRY_AFTER}; +use serde::Deserialize; use serde_json::{Map, Value, json}; use switchyard_protocol::{ LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStream, LlmResponseStreamEvent, Metadata, @@ -58,6 +59,20 @@ const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(250); const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(2); const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); +/// OpenAI Chat reasoning field dialect a target expects for assistant reasoning. +/// +/// Some OpenAI-compatible models only accept reasoning replayed under the exact +/// field they produced, so a target can pin the spelling here instead of +/// relying on whatever the inbound request carried. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningFormat { + /// The `reasoning` field. + OpenAi, + /// The `reasoning_content` field. + Deepseek, +} + /// How one model is served: the `default_backend` used when the request does not /// pin a wire format, plus any `other_backends` reachable over additional formats. #[derive(Clone, Debug)] @@ -65,6 +80,7 @@ pub struct ModelConfig { model_name: ModelId, default_backend: Backend, other_backends: Option>, + reasoning_format: Option, } impl ModelConfig { @@ -79,8 +95,17 @@ impl ModelConfig { model_name: model_name.into(), default_backend, other_backends, + reasoning_format: None, } } + + /// Pins the OpenAI Chat reasoning field the target expects. After encoding, + /// assistant reasoning is moved to this field and the other spelling is + /// removed. Unset keeps the encoded field unchanged. + pub fn with_reasoning_format(mut self, reasoning_format: ReasoningFormat) -> Self { + self.reasoning_format = Some(reasoning_format); + self + } } /// A model-bearing provider operation outside the normal completion endpoint. @@ -141,6 +166,21 @@ impl TranslatingLlmClient { // Pre-populate so concurrent calls update only atomics and keep map access lock-free. let mut unavailable_until = HashMap::new(); for config in model_configs { + if config.reasoning_format.is_some() + && !matches!(config.default_backend, Backend::OpenAiChat(_)) + && !config + .other_backends + .iter() + .flatten() + .any(|backend| matches!(backend, Backend::OpenAiChat(_))) + { + return Err(LlmClientError::Configuration { + message: format!( + "model {} reasoning_format requires an OpenAI Chat backend", + config.model_name + ), + }); + } for backend in std::iter::once(&config.default_backend) .chain(config.other_backends.iter().flatten()) { @@ -200,6 +240,13 @@ impl TranslatingLlmClient { self.backend_for(model, operation.wire_format()).is_some() } + /// The OpenAI Chat reasoning field dialect `model`'s target pinned, if any. + fn reasoning_format(&self, model: &ModelId) -> Option { + self.model_to_config + .get(model) + .and_then(|config| config.reasoning_format) + } + /// Reads llama.cpp model properties through the backend's configured auth and headers. /// /// Redirects are disabled so credentials cannot move to another origin. @@ -354,6 +401,11 @@ impl TranslatingLlmClient { } if matches!(backend, Backend::OpenAiChat(_)) { ensure_openai_stream_usage(&mut body); + // The target's field choice wins over whatever spelling the inbound + // request carried; the two fields are never emitted together. + if let Some(reasoning_format) = self.reasoning_format(model) { + apply_reasoning_format(&mut body, reasoning_format); + } } let streaming = endpoint.allows_streaming() && body.get("stream").and_then(Value::as_bool).unwrap_or(false); @@ -1295,6 +1347,58 @@ fn apply_reasoning_effort(body: &mut Value, backend: &Backend) { } } +// Moves assistant reasoning to the OpenAI Chat field the selected target +// expects, removing the other spelling. The value keeps the text it carried; +// when a message somehow carries both spellings, the target field's own value +// wins rather than concatenating the two. +fn apply_reasoning_format(body: &mut Value, reasoning_format: ReasoningFormat) { + let (target_field, other_field) = match reasoning_format { + ReasoningFormat::OpenAi => ("reasoning", "reasoning_content"), + ReasoningFormat::Deepseek => ("reasoning_content", "reasoning"), + }; + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { + return; + }; + for message in messages { + let Some(message) = message.as_object_mut() else { + continue; + }; + if message.get("role").and_then(Value::as_str) != Some("assistant") { + continue; + } + let value = message.remove(other_field).filter(|value| !value.is_null()); + if message + .get(target_field) + .is_some_and(|value| !value.is_null()) + { + continue; + } + let value = value.or_else(|| { + let details = message.get("reasoning_details")?.as_array()?; + let text = details + .iter() + .filter_map(|detail| { + detail + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .or_else(|| { + detail + .get("summary") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + }) + }) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(Value::String(text)) + }); + if let Some(value) = value { + message.insert(target_field.to_owned(), value); + } + } +} + // Applies target defaults without overriding fields supplied by the caller. fn merge_extra_body(body: &mut Value, extra_body: &BTreeMap) { let Value::Object(object) = body else { @@ -1588,6 +1692,18 @@ mod tests { vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)] } + // A one-model config list: "gpt" served over OpenAI Chat, with the target's + // reasoning field pinned to `reasoning_format`. + fn chat_map_with_reasoning_format( + base_url: &str, + reasoning_format: ReasoningFormat, + ) -> Vec { + vec![ + ModelConfig::new("gpt", Backend::OpenAiChat(config(base_url)), None) + .with_reasoning_format(reasoning_format), + ] + } + fn responses_map_with_effort(base_url: &str, effort: &str) -> Vec { let mut backend = config(base_url); backend.reasoning_effort = Some(effort.to_string()); @@ -3232,8 +3348,325 @@ mod tests { Ok(()) } - // Raw path, buffered: decode an OpenAI Chat body -> call -> encode back to OpenAI - // Chat JSON, with the served `model` restamped over the id the caller addressed. + // A configured dialect must have a reachable Chat backend, including fallbacks. + #[test] + fn reasoning_format_requires_a_chat_backend() -> std::result::Result<(), Box> { + for backend in [ + Backend::Anthropic(config("https://example.com")), + Backend::OpenAiResponses(config("https://example.com")), + ] { + let model = ModelConfig::new("gpt", backend.clone(), None); + TranslatingLlmClient::new(std::slice::from_ref(&model))?; + let result = + TranslatingLlmClient::new( + &[model.with_reasoning_format(ReasoningFormat::Deepseek)], + ); + assert!(matches!(result, Err(LlmClientError::Configuration { .. }))); + + let mixed = ModelConfig::new( + "gpt", + backend, + Some(vec![Backend::OpenAiChat(config("https://example.com"))]), + ) + .with_reasoning_format(ReasoningFormat::Deepseek); + let client = TranslatingLlmClient::new(&[mixed])?; + assert!( + client + .backend_for(&ModelId::from("gpt"), WireFormat::OpenAiChat) + .is_some() + ); + } + Ok(()) + } + + // Provider extension fields on other message roles must survive normalization. + #[test] + fn reasoning_format_preserves_non_assistant_messages() { + let untouched = json!([ + {"role": "user", "reasoning": "user extension", "reasoning_content": "keep"}, + {"role": "system", "reasoning_content": "system extension"}, + {"role": "tool", "reasoning": "tool extension"}, + {"reasoning_content": "unknown role"}, + ]); + for format in [ReasoningFormat::OpenAi, ReasoningFormat::Deepseek] { + let mut body = json!({"messages": untouched.clone()}); + apply_reasoning_format(&mut body, format); + assert_eq!(body["messages"], untouched); + } + } + + #[tokio::test] + async fn reasoning_format_replays_details_across_formats_and_streaming() + -> std::result::Result<(), Box> { + for streaming in [false, true] { + for (format, mut request) in [ + ( + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "assistant", "content": "answer", "reasoning_details": [{"type": "reasoning.text", "text": "thoughts"}]}]}), + ), + ( + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thoughts"}]}, {"role": "assistant", "content": "answer"}]}), + ), + ( + WireFormat::AnthropicMessages, + json!({"model": "gpt", "max_tokens": 32, "messages": [{"role": "assistant", "content": [{"type": "thinking", "thinking": "thoughts"}, {"type": "text", "text": "answer"}]}]}), + ), + ] { + let server = MockServer::start().await; + let response = if streaming { + ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream") + } else { + chat_success_response() + }; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + let client = TranslatingLlmClient::new(&chat_map_with_reasoning_format( + &format!("{}/v1", server.uri()), + ReasoningFormat::Deepseek, + ))?; + request["stream"] = json!(streaming); + client + .call_rewrite_model_raw(request, None, Some(&ModelId::new("gpt")), format) + .await?; + let requests = server + .received_requests() + .await + .ok_or("recording disabled")?; + let body: Value = serde_json::from_slice(&requests[0].body)?; + assert_eq!(body["stream"].as_bool().unwrap_or(false), streaming); + assert!( + body["messages"] + .as_array() + .ok_or("missing messages")? + .iter() + .any(|message| message["reasoning_content"] == "thoughts"), + "{format:?}: {body}" + ); + } + } + Ok(()) + } + + #[tokio::test] + async fn reasoning_format_replays_null_fields_with_tools() + -> std::result::Result<(), Box> { + for (format, target_field, other_field) in [ + (ReasoningFormat::Deepseek, "reasoning_content", "reasoning"), + (ReasoningFormat::OpenAi, "reasoning", "reasoning_content"), + ] { + for alternate in [None, Some(Value::Null), Some(json!("plain thoughts"))] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(chat_success_response()) + .expect(1) + .mount(&server) + .await; + let client = TranslatingLlmClient::new(&chat_map_with_reasoning_format( + &format!("{}/v1", server.uri()), + format, + ))?; + let mut request = json!({ + "model": "gpt", + "tools": [{"type": "function", "function": { + "name": "lookup", "parameters": {"type": "object"} + }}], + "messages": [ + {"role": "assistant", "content": null, (target_field): null, + "reasoning_details": [{"type": "reasoning.text", "text": "thoughts"}], + "tool_calls": [{"id": "call_1", "type": "function", "function": { + "name": "lookup", "arguments": "{}" + }}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "result"} + ] + }); + let expected = alternate + .as_ref() + .and_then(Value::as_str) + .unwrap_or("thoughts"); + if let Some(value) = &alternate { + request["messages"][0][other_field] = value.clone(); + } + client + .call_rewrite_model_raw( + request, + None, + Some(&ModelId::new("gpt")), + WireFormat::OpenAiChat, + ) + .await?; + let received = server + .received_requests() + .await + .ok_or("recording disabled")?; + let body: Value = serde_json::from_slice(&received[0].body)?; + assert_eq!(body["messages"][0][target_field], expected); + assert!(body["messages"][0].get(other_field).is_none()); + assert_eq!(body["tools"][0]["function"]["name"], "lookup"); + assert_eq!(body["messages"][1]["tool_call_id"], "call_1"); + } + } + Ok(()) + } + + #[tokio::test] + async fn reasoning_format_openai_moves_incoming_reasoning_content() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(chat_success_response()) + .mount(&server) + .await; + + let client = TranslatingLlmClient::new(&chat_map_with_reasoning_format( + &format!("{}/v1", server.uri()), + ReasoningFormat::OpenAi, + ))?; + let raw = json!({ + "model": "gpt", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "answer", "reasoning_content": "thoughts"}, + ], + }); + client + .call_rewrite_model_raw( + raw, + None, + Some(&ModelId::from("gpt")), + WireFormat::OpenAiChat, + ) + .await?; + + let received = server + .received_requests() + .await + .ok_or("request recording should be enabled")?; + let received = received.first().ok_or("expected one upstream request")?; + let body: Value = serde_json::from_slice(&received.body)?; + let assistant = &body["messages"][1]; + // The incoming `reasoning_content` spelling conflicts with the target's + // `openai` setting; the conflict resolves to the target's spelling. + assert_eq!(assistant["reasoning"], json!("thoughts")); + assert!(assistant.get("reasoning_content").is_none()); + Ok(()) + } + + #[tokio::test] + async fn reasoning_format_deepseek_moves_incoming_reasoning() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(chat_success_response()) + .mount(&server) + .await; + + let client = TranslatingLlmClient::new(&chat_map_with_reasoning_format( + &format!("{}/v1", server.uri()), + ReasoningFormat::Deepseek, + ))?; + let raw = json!({ + "model": "gpt", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "answer", "reasoning": "thoughts"}, + ], + }); + client + .call_rewrite_model_raw( + raw, + None, + Some(&ModelId::from("gpt")), + WireFormat::OpenAiChat, + ) + .await?; + + let received = server + .received_requests() + .await + .ok_or("request recording should be enabled")?; + let received = received.first().ok_or("expected one upstream request")?; + let body: Value = serde_json::from_slice(&received.body)?; + let assistant = &body["messages"][1]; + assert_eq!(assistant["reasoning_content"], json!("thoughts")); + assert!(assistant.get("reasoning").is_none()); + Ok(()) + } + + // Without a target setting the encoded field is left unchanged: unset keeps + // the current behavior. + #[tokio::test] + async fn unset_reasoning_format_keeps_incoming_spelling() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(chat_success_response()) + .mount(&server) + .await; + + let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?; + let raw = json!({ + "model": "gpt", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "answer", "reasoning_content": "thoughts"}, + ], + }); + client + .call_rewrite_model_raw( + raw, + None, + Some(&ModelId::from("gpt")), + WireFormat::OpenAiChat, + ) + .await?; + + let received = server + .received_requests() + .await + .ok_or("request recording should be enabled")?; + let received = received.first().ok_or("expected one upstream request")?; + let body: Value = serde_json::from_slice(&received.body)?; + let assistant = &body["messages"][1]; + assert_eq!(assistant["reasoning_content"], json!("thoughts")); + assert!(assistant.get("reasoning").is_none()); + Ok(()) + } + + // The two spellings are never emitted together: when a message somehow + // carries both, the target field's own value wins and the other is dropped. + #[test] + fn reasoning_format_never_emits_both_fields() { + let mut body = json!({ + "messages": [ + {"role": "assistant", "content": "answer", + "reasoning": "target spelling", "reasoning_content": "other spelling"}, + ], + }); + apply_reasoning_format(&mut body, ReasoningFormat::OpenAi); + let message = &body["messages"][0]; + assert_eq!(message["reasoning"], json!("target spelling")); + assert!(message.get("reasoning_content").is_none()); + + let mut body = json!({ + "messages": [ + {"role": "assistant", "content": "answer", + "reasoning": "other spelling", "reasoning_content": "target spelling"}, + ], + }); + apply_reasoning_format(&mut body, ReasoningFormat::Deepseek); + let message = &body["messages"][0]; + assert_eq!(message["reasoning_content"], json!("target spelling")); + assert!(message.get("reasoning").is_none()); + } #[tokio::test] async fn call_rewrite_model_raw_round_trips_buffered_json() -> std::result::Result<(), Box> { diff --git a/crates/libsy-llm-client/src/lib.rs b/crates/libsy-llm-client/src/lib.rs index 414a32167..b18d65cce 100644 --- a/crates/libsy-llm-client/src/lib.rs +++ b/crates/libsy-llm-client/src/lib.rs @@ -33,7 +33,7 @@ pub mod run; mod system_one; pub use backend::{Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig}; -pub use client::{AuxiliaryOperation, ModelConfig, TranslatingLlmClient}; +pub use client::{AuxiliaryOperation, ModelConfig, ReasoningFormat, TranslatingLlmClient}; pub use error::{LlmClientError, Result}; pub use observation::{ModelCallObservation, RunObservation, RunObserver}; pub use openai_decisions::OpenAiDecisionClient; diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index 5d7b2c8fc..117587ac2 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Deserializer}; use serde_json::Value; use switchyard_llm_client::{ AuxiliaryOperation, Backend, ClientRouter, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig, - OpenAiDecisionClient, SystemOneClient, TranslatingLlmClient, + OpenAiDecisionClient, ReasoningFormat, SystemOneClient, TranslatingLlmClient, }; use switchyard_protocol::{ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat}; @@ -228,11 +228,12 @@ impl DeploymentConfig { std::collections::hash_map::Entry::Occupied(slot) => { let (first_name, first) = slot.get(); if first.reasoning_effort != target.reasoning_effort + || first.reasoning_format != target.reasoning_format || first.extra_body != target.extra_body || first.omit_body_fields != target.omit_body_fields { return Err(RunnerError::configuration(format!( - "targets {first_name} and {target_name} both name model {} on llm client {} but with different reasoning_effort, extra_body, or omit_body_fields; one target per model id is kept, so give each its own model id or llm client", + "targets {first_name} and {target_name} both name model {} on llm client {} but with different reasoning_effort, reasoning_format, extra_body, or omit_body_fields; one target per model id is kept, so give each its own model id or llm client", target.id, target.llm_client ))); } @@ -387,7 +388,14 @@ impl DeploymentConfig { ))); } } - model_configs.push(ModelConfig::new( + if target.reasoning_format.is_some() + && !matches!(client_config.format, ClientFormat::OpenAiChat) + { + return Err(RunnerError::configuration(format!( + "target {target_name} reasoning_format is only supported on openai_chat clients" + ))); + } + let mut model_config = ModelConfig::new( target.id.clone(), build_backend( &target.llm_client, @@ -397,7 +405,11 @@ impl DeploymentConfig { target.reasoning_effort.clone(), )?, None, - )); + ); + if let Some(reasoning_format) = target.reasoning_format { + model_config = model_config.with_reasoning_format(reasoning_format); + } + model_configs.push(model_config); } let mut clients = BTreeMap::new(); @@ -806,6 +818,10 @@ struct TargetConfig { /// Reasoning effort forced on every request to this target, replacing the caller's value. /// Only meaningful on `openai_chat` and `openai_responses` clients. reasoning_effort: Option, + /// Which OpenAI Chat wire field the target expects assistant reasoning under + /// (`openai` for `reasoning`, `deepseek` for `reasoning_content`). Unset keeps + /// the encoded field unchanged. Only meaningful on `openai_chat` clients. + reasoning_format: Option, } #[derive(Clone, Copy, Debug, Deserialize)] @@ -1598,11 +1614,24 @@ classify_trigger"#, ), ); assert!( - error_message(&conflicting) - .contains("different reasoning_effort, extra_body, or omit_body_fields"), + error_message(&conflicting).contains( + "different reasoning_effort, reasoning_format, extra_body, or omit_body_fields" + ), "{}", error_message(&conflicting) ); + let chat = "[targets.classifier]\nid = \"classifier/model\"\nllm_client = \"primary\""; + let format_conflict = VALID_CONFIG.replace( + chat, + &format!("{chat}\nreasoning_format = \"openai\"\n\n[targets.classifier_alias]\nid = \"classifier/model\"\nllm_client = \"primary\"\nreasoning_format = \"deepseek\""), + ); + assert!( + error_message(&format_conflict).contains( + "different reasoning_effort, reasoning_format, extra_body, or omit_body_fields" + ), + "{}", + error_message(&format_conflict) + ); // An alias with identical settings is still allowed (it only warns). let alias = VALID_CONFIG.replace( strong, @@ -1612,6 +1641,60 @@ classify_trigger"#, Ok(()) } + #[test] + fn reasoning_format_is_rejected_on_non_chat_clients() { + // Reasoning fields are an OpenAI Chat wire concept; the anthropic client rejects it. + let anthropic = VALID_CONFIG.replace( + "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"", + "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"\nreasoning_format = \"openai\"", + ); + assert!( + error_message(&anthropic) + .contains("reasoning_format is only supported on openai_chat clients"), + "{}", + error_message(&anthropic) + ); + // The responses client rejects it too: the setting only applies to Chat targets. + let responses = VALID_CONFIG.replace( + "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"", + "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"\nreasoning_format = \"deepseek\"", + ); + assert!( + error_message(&responses) + .contains("reasoning_format is only supported on openai_chat clients"), + "{}", + error_message(&responses) + ); + } + + #[test] + fn reasoning_format_accepts_openai_and_deepseek_on_chat_clients() -> RunnerResult<()> { + // The classifier target sits on the openai_chat client. + let chat_target = + "[targets.classifier]\nid = \"classifier/model\"\nllm_client = \"primary\""; + let openai = VALID_CONFIG.replace( + chat_target, + &format!("{chat_target}\nreasoning_format = \"openai\""), + ); + runner_from_toml(&openai)?; + let deepseek = VALID_CONFIG.replace( + chat_target, + &format!("{chat_target}\nreasoning_format = \"deepseek\""), + ); + runner_from_toml(&deepseek)?; + // Unknown spellings fail at TOML parse time. + let unknown = VALID_CONFIG.replace( + chat_target, + &format!("{chat_target}\nreasoning_format = \"gpt\""), + ); + assert!( + error_message(&unknown).contains("unknown variant"), + "{}", + error_message(&unknown) + ); + Ok(()) + } + #[test] fn classifier_judge_completion_caps_are_configurable() -> RunnerResult<()> { let capability = VALID_CONFIG.replace( diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 9497adca6..381452c46 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -158,6 +158,7 @@ such clients. | `extra_body` | No | `{}` | Values merged into the upstream request when the request does not already set that key. | | `omit_body_fields` | No | `[]` | Top-level fields removed from every request body that Switchyard sends to this target. Switchyard removes them after it translates the request to the LLM client's `format`, so use that format's field names, for example `reasoning_effort` on `openai_chat` or `reasoning` on `openai_responses`. Switchyard applies `extra_body` and `reasoning_effort` after the removal, so either can set a removed field again. | | `reasoning_effort` | No | unset | Reasoning effort forced on every request to this target, replacing the value the caller sent (`reasoning.effort` on `openai_responses`, `reasoning_effort` on `openai_chat`). Rejected on `anthropic_messages` clients. Use it to run one target at a different effort than the client asked for, for example a strong tier at `max` behind a client that sends `high`. Targets with different effort settings need distinct model IDs when used within one route. Separate routes may use the same model ID with separate `llm_clients` entries (same endpoint, different name). | +| `reasoning_format` | No | unset | OpenAI Chat field the target expects assistant reasoning under: `"openai"` for `reasoning`, `"deepseek"` for `reasoning_content`. After encoding, including untouched same-format requests, assistant reasoning is moved to this field and the other spelling is removed; the two are never sent together. Text from `reasoning_details` is used when neither plain field exists. DeepSeek requires replayed reasoning on tool-call turns. Unset keeps the encoded field unchanged. Rejected on non-`openai_chat` clients. Use it when a model only accepts reasoning replayed under the exact field it produced. Targets with different format settings need distinct model IDs, same as `reasoning_effort` and `extra_body`. | Within one route, callable targets with the same model ID must use the same `llm_client`. This includes completion targets and targets used for judging or classification. Switchyard rejects