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
14 changes: 11 additions & 3 deletions crates/switchyard-runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ struct RouteConfig {
context_window: Option<u32>,
tool_calling: Option<bool>,
reasoning: Option<bool>,
reasoning_summaries: Option<bool>,
vision: Option<bool>,
algorithm: AlgorithmSpec,
}
Expand All @@ -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)?;
Expand All @@ -94,6 +96,7 @@ impl<'de> Deserialize<'de> for RouteConfig {
context_window,
tool_calling,
reasoning,
reasoning_summaries,
vision,
algorithm,
})
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Comment thread
eugenn marked this conversation as resolved.
targets = ["fast", "strong"]
weights = [1.0, 2.0]
seed = 7
Expand All @@ -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"]);
}

Expand Down
13 changes: 13 additions & 0 deletions crates/switchyard-runner/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ pub struct Route {
clients: ClientRouter,
caller_auth: Option<CallerAuthKind>,
capabilities: ModelCapabilities,
reasoning_summaries: Option<bool>,
anthropic_auxiliary_target: Option<AuxiliaryTarget>,
responses_auxiliary_target: Option<AuxiliaryTarget>,
decision_targets: Vec<DecisionTarget>,
Expand Down Expand Up @@ -149,6 +150,7 @@ impl Route {
clients,
caller_auth,
capabilities,
reasoning_summaries: None,
anthropic_auxiliary_target,
responses_auxiliary_target,
decision_targets,
Expand All @@ -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<bool>) -> Self {
self.reasoning_summaries = reasoning_summaries;
self
}

/// Returns whether Codex can send reasoning summary controls.
pub fn reasoning_summaries(&self) -> Option<bool> {
self.reasoning_summaries
}

/// Returns the forwarded caller credential family.
pub fn caller_auth(&self) -> Option<CallerAuthKind> {
self.caller_auth
Expand Down
5 changes: 5 additions & 0 deletions crates/switchyard-runner/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ impl Runner {
})
}

/// Iterates over configured model IDs and their routes.
pub fn model_routes(&self) -> impl Iterator<Item = (&ModelId, &Route)> {
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()
Expand Down
41 changes: 28 additions & 13 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,12 +1383,15 @@ fn error_response(
}

async fn models(State(state): State<ServerState>) -> Json<Value> {
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<ServerState>) -> Json<StatsSnapshot> {
Expand Down Expand Up @@ -1469,20 +1472,25 @@ async fn not_found() -> Response {
}

fn model_list_payload<'a>(
entries: impl IntoIterator<Item = (&'a str, ModelCapabilities)>,
entries: impl IntoIterator<Item = (&'a str, ModelCapabilities, Option<bool>)>,
) -> Value {
let mut entries = entries.into_iter().collect::<Vec<_>>();
entries.sort_unstable_by_key(|(model_id, _)| *model_id);
let model_ids = entries.iter().map(|(model, _)| *model).collect::<Vec<_>>();
entries.sort_unstable_by_key(|(model_id, _, _)| *model_id);
let model_ids = entries
.iter()
.map(|(model, _, _)| *model)
.collect::<Vec<_>>();
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::<Vec<_>>(),
"data": entries.iter().map(|(model, caps, _)| model_entry_json(model, *caps)).collect::<Vec<_>>(),
"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::<Vec<_>>(),
"first_id": first_id,
"last_id": last_id,
Expand Down Expand Up @@ -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<bool>,
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,
Expand All @@ -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 },
Expand Down
30 changes: 29 additions & 1 deletion crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2347,7 +2354,7 @@ target = "shared"
.collect::<BTreeMap<_, _>>();
// 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)
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions docs/reference/toml_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down