From da1e6e1e9f04f41c0339e1a7be7b5a49fa54a8dd Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Wed, 9 Sep 2026 20:18:39 +0300 Subject: [PATCH] fix(server): separate Codex reasoning summary capability Signed-off-by: Eugen Nekhai --- crates/switchyard-runner/src/config.rs | 14 ++++++-- crates/switchyard-runner/src/route.rs | 13 ++++++++ crates/switchyard-runner/src/runner.rs | 5 +++ crates/switchyard-server/src/lib.rs | 41 ++++++++++++++++-------- crates/switchyard-server/tests/server.rs | 30 ++++++++++++++++- docs/reference/toml_schema.md | 1 + 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index a4bb309aa..9c8234fd7 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -67,6 +67,7 @@ struct RouteConfig { context_window: Option, tool_calling: Option, reasoning: Option, + reasoning_summaries: Option, vision: Option, algorithm: AlgorithmSpec, } @@ -86,6 +87,7 @@ impl<'de> Deserialize<'de> for RouteConfig { let context_window = take_optional(&mut table, "context_window")?; let tool_calling = take_optional(&mut table, "tool_calling")?; let reasoning = take_optional(&mut table, "reasoning")?; + let reasoning_summaries = take_optional(&mut table, "reasoning_summaries")?; let vision = take_optional(&mut table, "vision")?; let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table)) .map_err(serde::de::Error::custom)?; @@ -94,6 +96,7 @@ impl<'de> Deserialize<'de> for RouteConfig { context_window, tool_calling, reasoning, + reasoning_summaries, vision, algorithm, }) @@ -216,7 +219,8 @@ impl DeploymentConfig { anthropic_auxiliary_target, responses_auxiliary_target, decision_targets, - ); + ) + .with_reasoning_summaries(config.reasoning_summaries); routes.push((config.id.clone(), route)); } let runner = Runner::new(routes).with_fallback_url(fallback_base_url); @@ -590,13 +594,16 @@ mod tests { #[test] fn route_presentation_fields_are_split_from_the_algorithm() { + // `reasoning` and `reasoning_summaries` are independent route settings: a route + // can advertise reasoning while declining `reasoning.summary`. let route: RouteConfig = toml::from_str( r#" type = "random" id = "switchyard/random" context_window = 128000 tool_calling = true -reasoning = false +reasoning = true +reasoning_summaries = false targets = ["fast", "strong"] weights = [1.0, 2.0] seed = 7 @@ -607,7 +614,8 @@ seed = 7 assert_eq!(route.id, "switchyard/random"); assert_eq!(route.context_window, Some(128_000)); assert_eq!(route.tool_calling, Some(true)); - assert_eq!(route.reasoning, Some(false)); + assert_eq!(route.reasoning, Some(true)); + assert_eq!(route.reasoning_summaries, Some(false)); assert_eq!(route.algorithm.routing_target_names(), ["fast", "strong"]); } diff --git a/crates/switchyard-runner/src/route.rs b/crates/switchyard-runner/src/route.rs index 89f6e722c..02db2b1ab 100644 --- a/crates/switchyard-runner/src/route.rs +++ b/crates/switchyard-runner/src/route.rs @@ -122,6 +122,7 @@ pub struct Route { clients: ClientRouter, caller_auth: Option, capabilities: ModelCapabilities, + reasoning_summaries: Option, anthropic_auxiliary_target: Option, responses_auxiliary_target: Option, decision_targets: Vec, @@ -149,6 +150,7 @@ impl Route { clients, caller_auth, capabilities, + reasoning_summaries: None, anthropic_auxiliary_target, responses_auxiliary_target, decision_targets, @@ -165,6 +167,17 @@ impl Route { self.capabilities } + /// Sets whether Codex can send reasoning summary controls. + pub fn with_reasoning_summaries(mut self, reasoning_summaries: Option) -> Self { + self.reasoning_summaries = reasoning_summaries; + self + } + + /// Returns whether Codex can send reasoning summary controls. + pub fn reasoning_summaries(&self) -> Option { + self.reasoning_summaries + } + /// Returns the forwarded caller credential family. pub fn caller_auth(&self) -> Option { self.caller_auth diff --git a/crates/switchyard-runner/src/runner.rs b/crates/switchyard-runner/src/runner.rs index 36ecb6410..7055a1d76 100644 --- a/crates/switchyard-runner/src/runner.rs +++ b/crates/switchyard-runner/src/runner.rs @@ -86,6 +86,11 @@ impl Runner { }) } + /// Iterates over configured model IDs and their routes. + pub fn model_routes(&self) -> impl Iterator { + self.routes.iter().map(|(id, route)| (id, route)) + } + /// Returns the validated API root used for unmatched HTTP requests. pub fn fallback_base_url(&self) -> Option<&str> { self.fallback_base_url.as_deref() diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 8b6f06de8..715ae1c81 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -1383,12 +1383,15 @@ fn error_response( } async fn models(State(state): State) -> Json { - Json(model_list_payload( - state - .runner - .models() - .map(|model| (model.id.as_str(), model.capabilities)), - )) + Json(model_list_payload(state.runner.model_routes().map( + |(id, route)| { + ( + id.as_str(), + route.capabilities(), + route.reasoning_summaries(), + ) + }, + ))) } async fn get_stats(State(state): State) -> Json { @@ -1469,20 +1472,25 @@ async fn not_found() -> Response { } fn model_list_payload<'a>( - entries: impl IntoIterator, + entries: impl IntoIterator)>, ) -> Value { let mut entries = entries.into_iter().collect::>(); - entries.sort_unstable_by_key(|(model_id, _)| *model_id); - let model_ids = entries.iter().map(|(model, _)| *model).collect::>(); + entries.sort_unstable_by_key(|(model_id, _, _)| *model_id); + let model_ids = entries + .iter() + .map(|(model, _, _)| *model) + .collect::>(); let first_id = model_ids.first().copied(); let last_id = model_ids.last().copied(); json!({ "object": "list", - "data": entries.iter().map(|(model, caps)| model_entry_json(model, *caps)).collect::>(), + "data": entries.iter().map(|(model, caps, _)| model_entry_json(model, *caps)).collect::>(), "models": entries .iter() .enumerate() - .map(|(priority, (model, caps))| codex_model_entry_json(model, *caps, priority)) + .map(|(priority, (model, caps, summaries))| { + codex_model_entry_json(model, *caps, *summaries, priority) + }) .collect::>(), "first_id": first_id, "last_id": last_id, @@ -1533,12 +1541,18 @@ fn model_entry_json(model: &str, capabilities: ModelCapabilities) -> Value { // supported_parameters — and fall back to the route's declared value. Some backends // publish nothing (the NVIDIA gateway returns id-only models and blocks /model/info), // so keep failing closed to config. -fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority: usize) -> Value { +fn codex_model_entry_json( + model: &str, + capabilities: ModelCapabilities, + reasoning_summaries: Option, + priority: usize, +) -> Value { // Codex is non-functional without shell and apply_patch, so an undeclared tool // capability defaults to enabled here; the OpenAI `data` entry reports the raw // Option separately for clients that want the undeclared state. let tool_calling = capabilities.tool_calling.unwrap_or(true); let reasoning = capabilities.reasoning.unwrap_or(false); + let reasoning_summaries = reasoning_summaries.unwrap_or(reasoning); json!({ "slug": model, "display_name": model, @@ -1556,7 +1570,8 @@ fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority // Required `ModelInfo` string. Unlike the launcher, the server cannot read // Codex's bundled prompt, so it sends a minimal stub. "base_instructions": "You are Codex, a coding agent.", - "supports_reasoning_summaries": reasoning, + "supports_reasoning_summaries": reasoning_summaries, + "supports_reasoning_summary_parameter": reasoning_summaries, "default_reasoning_summary": "none", "support_verbosity": reasoning, "default_verbosity": if reasoning { json!("low") } else { Value::Null }, diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 0f6567092..3d09ad6f1 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -2318,6 +2318,13 @@ type = "passthrough" target = "shared" reasoning = true +[routes.reasoning_without_summaries] +id = "reasoning-without-summaries" +type = "passthrough" +target = "shared" +reasoning = true +reasoning_summaries = false + [routes.undeclared] id = "undeclared" type = "passthrough" @@ -2347,7 +2354,7 @@ target = "shared" .collect::>(); // This checks the shape the server emits. That Codex 0.144.5 actually decodes it // (context_window: null included) is verified by a live Codex run in SWITCH-1225. - assert_eq!(codex_metadata.len(), 4); + assert_eq!(codex_metadata.len(), 5); assert_eq!( codex_metadata["declared"]["context_window"], json!(1_000_000) @@ -2393,11 +2400,32 @@ target = "shared" codex_metadata["reasoning"]["supports_reasoning_summaries"], json!(true) ); + assert_eq!( + codex_metadata["reasoning"]["supports_reasoning_summary_parameter"], + json!(true) + ); assert_eq!( codex_metadata["reasoning"]["support_verbosity"], json!(true) ); assert_eq!(codex_metadata["reasoning"]["default_verbosity"], "low"); + // Disabling summaries must keep the route's reasoning effort controls. + assert_eq!( + codex_metadata["reasoning-without-summaries"]["supports_reasoning_summaries"], + json!(false) + ); + assert_eq!( + codex_metadata["reasoning-without-summaries"]["supports_reasoning_summary_parameter"], + json!(false) + ); + assert_eq!( + codex_metadata["reasoning-without-summaries"]["default_reasoning_level"], + codex_metadata["reasoning"]["default_reasoning_level"] + ); + assert_eq!( + codex_metadata["reasoning-without-summaries"]["supported_reasoning_levels"], + codex_metadata["reasoning"]["supported_reasoning_levels"] + ); // An undeclared route: null context window, non-reasoning, but tools default on so Codex // remains usable when connected directly to the server. assert_eq!(codex_metadata["undeclared"]["context_window"], json!(null)); diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 2708b68db..0da5c92b4 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -113,6 +113,7 @@ Every route takes the common keys below, plus the keys for its type. | `context_window` | No | unset | Positive token count advertised for this route by `GET /v1/models`. Unset values appear as `null`. This does not enforce a request limit. | | `tool_calling` | No | unset | Whether `GET /v1/models` advertises tool-calling support for this route. Unset values appear as `null`. | | `reasoning` | No | unset | Whether `GET /v1/models` advertises reasoning support to Codex direct-provider discovery. Unset routes are advertised as non-reasoning. | +| `reasoning_summaries` | No | `reasoning` | Whether Codex direct-provider discovery advertises support for `reasoning.summary`. Set this to `false` when a route supports reasoning effort but not summaries. Codex 0.144.x also disables reasoning effort, while Codex 0.145+ retains reasoning effort and omits `reasoning.summary`. | | `vision` | No | unset | Whether `GET /v1/models` advertises **image input** to Codex direct-provider discovery. Unset routes are advertised as text-only. This is not cosmetic: Codex reads `input_modalities` from the model card and, when it reads text-only, replaces an attached image with the text `image content omitted because you do not support image input` **before sending**, so a route whose target can see but which does not declare `vision = true` loses the image in the client. Declare it only when every target the route can select accepts images. | ### `noop`