From c27affffb327804cddd7665d7f229d1d471afdf6 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Tue, 15 Sep 2026 20:33:57 +0300 Subject: [PATCH] fix(server): separate the Codex reasoning summary capability Signed-off-by: Eugen Nekhai --- benchmark/codex_model_catalog_lib.py | 2 ++ crates/switchyard-runner/src/config.rs | 6 ++++ crates/switchyard-runner/src/route.rs | 3 ++ crates/switchyard-server/src/lib.rs | 9 +++++- crates/switchyard-server/tests/server.rs | 37 +++++++++++++++++++++++- docs/reference/toml_schema.md | 1 + 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/benchmark/codex_model_catalog_lib.py b/benchmark/codex_model_catalog_lib.py index bcefe8530..c5e951588 100644 --- a/benchmark/codex_model_catalog_lib.py +++ b/benchmark/codex_model_catalog_lib.py @@ -48,6 +48,7 @@ def _fallback_codex_model_template() -> dict[str, Any]: "upgrade": None, "base_instructions": "You are Codex, a coding agent.", "supports_reasoning_summaries": True, + "supports_reasoning_summary_parameter": True, "default_reasoning_summary": "none", "support_verbosity": True, "default_verbosity": "low", @@ -108,6 +109,7 @@ def _build_codex_model_catalog( """Build Codex catalog JSON for Switchyard route ids.""" template = _load_codex_model_template(codex_bin) template.setdefault("supports_reasoning_summaries", True) + template.setdefault("supports_reasoning_summary_parameter", True) models: list[dict[str, Any]] = [] for priority, (model_id, display_name, description) in enumerate(entries): model = copy.deepcopy(template) diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index e2e1c7b14..1c9af2258 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -68,6 +68,7 @@ struct RouteConfig { context_window: Option, tool_calling: Option, reasoning: Option, + reasoning_summaries: Option, vision: Option, algorithm: AlgorithmSpec, } @@ -87,6 +88,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)?; @@ -95,6 +97,7 @@ impl<'de> Deserialize<'de> for RouteConfig { context_window, tool_calling, reasoning, + reasoning_summaries, vision, algorithm, }) @@ -127,6 +130,7 @@ impl RouteConfig { context_window: self.context_window, tool_calling: self.tool_calling, reasoning: self.reasoning, + reasoning_summaries: self.reasoning_summaries, vision: self.vision, } } @@ -684,6 +688,7 @@ id = "switchyard/random" context_window = 128000 tool_calling = true reasoning = false +reasoning_summaries = true targets = ["fast", "strong"] weights = [1.0, 2.0] seed = 7 @@ -695,6 +700,7 @@ seed = 7 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_summaries, Some(true)); 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 2b594646f..6e62a2f04 100644 --- a/crates/switchyard-runner/src/route.rs +++ b/crates/switchyard-runner/src/route.rs @@ -26,6 +26,9 @@ pub struct ModelCapabilities { /// probe this, so a route opts in via config; undeclared routes advertise as /// non-reasoning to Codex (fail closed). pub reasoning: Option, + /// Whether the routed model accepts reasoning summary controls. When unset, + /// model discovery preserves the legacy behavior and follows `reasoning`. + pub reasoning_summaries: Option, /// Whether the routed model accepts image input. Declared per route for the same /// reason as `reasoning`, and failing closed matters more here: a route may /// resolve to a target with no vision at all. diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index d239d66ad..926611a48 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -1548,6 +1548,7 @@ fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority // 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 = capabilities.reasoning_summaries.unwrap_or(reasoning); json!({ "slug": model, "display_name": model, @@ -1565,7 +1566,13 @@ 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, + // Codex renamed this capability. 0.144.x reads `supports_reasoning_summaries` + // and gates the whole reasoning control on it, so a false there also drops + // reasoning effort. 0.145+ reads `supports_reasoning_summary_parameter` and + // only omits `reasoning.summary`. Publish both so either version sees the + // route's declared value. + "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 7097a7529..b5469898b 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -2642,6 +2642,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" @@ -2671,7 +2678,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) @@ -2717,11 +2724,35 @@ 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"); + // Summary support defaults to reasoning for existing configurations, but routes + // that may select a target rejecting `reasoning.summary` can disable it. + assert_eq!( + codex_metadata["reasoning-without-summaries"]["default_reasoning_level"], + "xhigh" + ); + assert_eq!( + codex_metadata["reasoning-without-summaries"]["supported_reasoning_levels"] + .as_array() + .map(Vec::len), + Some(4) + ); + 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) + ); // 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)); @@ -2737,6 +2768,10 @@ target = "shared" codex_metadata["undeclared"]["supports_reasoning_summaries"], json!(false) ); + assert_eq!( + codex_metadata["undeclared"]["supports_reasoning_summary_parameter"], + json!(false) + ); assert_eq!(codex_metadata["undeclared"]["shell_type"], "shell_command"); assert_eq!( codex_metadata["undeclared"]["apply_patch_tool_type"], diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index b482ecb6e..94dde71aa 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -116,6 +116,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 any target the route can select supports reasoning effort but not summaries. Codex 0.144.x gates its whole reasoning control on this, so it also stops sending reasoning effort; Codex 0.145+ keeps reasoning effort and only 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`