diff --git a/CHANGELOG.md b/CHANGELOG.md index ba01265f..cb883ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,28 @@ target. upstream's 500 (`ProviderHttpError`), as the same error answered with HTTP 500 is, instead of 502 (`ProviderError`). +### Fixed + +- **A refused `none` is sent again with the lowest level, not without an + effort.** When an upstream answered 400 to the `none` that + `models.reasoning_effort: off` writes in OpenAI's formats (models that + take only `minimal`, `low`, `medium` or `high`), the request went out + again without the effort, so the model reasoned at its default level, + usually `medium`, and that was remembered for 7 days. It now goes out + with the lowest level the error names, and without the effort only when + the error names none (tw-dialect `think::none_fix`). +- **`off` reaches an upstream of another format.** For a request converted + for another format, such as an Anthropic caller routed to an OpenAI + Chat or Responses upstream, `off` took the caller's thinking out before + the conversion, so the converted request carried no effort and the + upstream reasoned at its default, while the log row's `"effort"` said + it was off. The setting is now applied to the converted request: OpenAI + Chat and Responses upstreams receive `none`, Gemini 2.5 Flash a thinking + budget of 0, and Anthropic and Bedrock upstreams no thinking, as + before. Gemini models that cannot stop thinking (2.5 Pro, Gemini 3) get + no thinking configuration, as before. The `"effort"` entry of a + converted hop records what that hop sent. + ## [3.9.1] — 2026-10-23 A conversation whose history a provider encrypted, such as a Codex diff --git a/Cargo.lock b/Cargo.lock index f54820bd..b2f831e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2944,7 +2944,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.21", "tokio", "tracing", @@ -2983,7 +2983,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -4837,8 +4837,8 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=14f52f25a434faf1e7336746b57c9ad8d8aebb96#14f52f25a434faf1e7336746b57c9ad8d8aebb96" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a#9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -4852,16 +4852,16 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=14f52f25a434faf1e7336746b57c9ad8d8aebb96#14f52f25a434faf1e7336746b57c9ad8d8aebb96" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a#9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" dependencies = [ "serde", ] [[package]] name = "tw-dialect" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=14f52f25a434faf1e7336746b57c9ad8d8aebb96#14f52f25a434faf1e7336746b57c9ad8d8aebb96" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a#9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" dependencies = [ "memchr", "serde", @@ -4870,8 +4870,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=14f52f25a434faf1e7336746b57c9ad8d8aebb96#14f52f25a434faf1e7336746b57c9ad8d8aebb96" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a#9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" dependencies = [ "aho-corasick", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 2c5de095..592e84d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,10 +70,10 @@ opt-level = 3 # never re-exported through a local shim. And the reverse: something only # this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error) # lives here, not in core. -tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" } -tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" } -tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "14f52f25a434faf1e7336746b57c9ad8d8aebb96" } +tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" } +tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" } +tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a" } # Web framework axum = { version = "0.8", features = ["macros", "ws"] } diff --git a/crates/gateway/src/proxy/effort.rs b/crates/gateway/src/proxy/effort.rs index 945c5026..8721e75c 100644 --- a/crates/gateway/src/proxy/effort.rs +++ b/crates/gateway/src/proxy/effort.rs @@ -8,22 +8,27 @@ //! on**; a request without it — a title, a summary, a client that never //! asked — goes out untouched: reasoning is never turned on for a caller. //! Unset: requests go out as sent. It applies to each hop as it is -//! addressed, to the caller's request in its own format and for the model -//! named upstream (Gemini's budgets are capped per model); a request -//! converted for another format carries the changed effort the way that -//! format writes it. What reasoning is on, and where each format writes the -//! effort, is `tw_dialect::params`, shared with the desktop gateway. +//! addressed, for the model named upstream (Gemini's budgets are capped +//! per model). A level is applied to the caller's request in its own +//! format; a request converted for another format carries the changed +//! effort the way that format writes it. `off` on a converted hop is +//! applied to the conversion instead ([`turn_off_converted`]): taking the +//! caller's reasoning out first would leave the converted request with no +//! effort at all, which OpenAI's models read as their default level. What +//! reasoning is on, and where each format writes the effort, is +//! `tw_dialect::params`, shared with the desktop gateway. //! //! # What an upstream refuses //! //! Not every model takes every level, and an upstream that does not says //! so with a 400 (`tw_dialect::think::effort_rejection`; for a `none` it -//! does not take, `none_rejection`). Both are read against the effort the +//! does not take, `none_fix`). Both are read against the effort the //! hop **wrote** (`tw_dialect::params::written_effort`), whether or not //! reasoning is on — Claude with thinking off still refuses `xhigh` — and a //! request that only carries a budget has none to refuse. The request then -//! goes to the **same upstream** once more, with the level it named or, -//! when it named none, without the effort (the model's default). That is +//! goes to the **same upstream** once more, with the level it named (for a +//! refused `none`, the lowest it named) or, when it named none, without the +//! effort (the model's default). That is //! not a retry of a failure of the moment: it does not wait and does not //! count against `gateway.retry_wait_secs` (`super::retry`). A second //! refusal is the answer. @@ -106,6 +111,30 @@ impl ReasoningSetting { } } +/// `off` on a hop converted for `target`, applied to the caller's decoded +/// request `r` before it is encoded. Whether reasoning was on and is +/// turned off; the caller's reasoning is read as for the same-format hop. +/// +/// The reasoning configuration is taken out, except for Gemini, where it is +/// marked off: Gemini 2.5 thinks without one, and the encoder writes a +/// zero budget for the models that can stop (2.5 Flash) and reports the +/// setting dropped for those that cannot. For OpenAI's formats the +/// converted body then needs `none` written in it +/// (`tw_dialect::params::write_reasoning_none`): without an effort their +/// models reason at their default level. +pub(crate) fn turn_off_converted(r: &mut tw_dialect::ir::Request, target: Dialect) -> bool { + if !r.reasoning.as_ref().is_some_and(|re| re.enabled) { + return false; + } + r.reasoning = (target == Dialect::Gemini).then_some(tw_dialect::ir::Reasoning { + enabled: false, + effort: None, + budget: None, + summary: false, + }); + true +} + /// What the caller's request asks for reasoning, read once. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct Asked { @@ -239,9 +268,7 @@ pub(crate) fn rejection(err: &GatewayError, label: &str, sent: WrittenEffort) -> .unwrap_or(message); match sent { WrittenEffort::Level(e) => tw_dialect::think::effort_rejection(*status, said, e), - WrittenEffort::Off => { - tw_dialect::think::none_rejection(*status, said).then_some(EffortFix::Drop) - } + WrittenEffort::Off => tw_dialect::think::none_fix(*status, said), } } @@ -589,12 +616,24 @@ mod tests { rejection(&http(400, claude), "relay", L(Effort::XHigh)), Some(EffortFix::Use(Effort::High)) ); - // `none` not taken: left out. + // `none` not taken, no level named: left out. let none = r#"{"error":{"message":"Invalid value for reasoning_effort: 'none'"}}"#; assert_eq!( rejection(&http(400, none), "relay", WrittenEffort::Off), Some(EffortFix::Drop) ); + // `none` not taken, levels named: the lowest, not the model's + // default, which would reason more. + let listed = r#"{"error":{"message":"Unsupported value: 'none' is not supported with this model. Supported values are: 'minimal', 'low', 'medium', and 'high'.","param":"reasoning_effort"}}"#; + assert_eq!( + rejection(&http(400, listed), "relay", WrittenEffort::Off), + Some(EffortFix::Use(Effort::Minimal)) + ); + let listed = r#"{"error":{"message":"Unsupported value: 'none' is not supported with this model. Supported values are: 'low', 'medium', and 'high'.","param":"reasoning.effort"}}"#; + assert_eq!( + rejection(&http(400, listed), "relay", WrittenEffort::Off), + Some(EffortFix::Use(Effort::Low)) + ); // Not a 400: an upstream failing is not a refusal. assert_eq!(rejection(&http(500, said), "relay", L(Effort::XHigh)), None); // A refusal of something else. diff --git a/crates/gateway/src/proxy/generate.rs b/crates/gateway/src/proxy/generate.rs index cd9b7d73..2c1c75b0 100644 --- a/crates/gateway/src/proxy/generate.rs +++ b/crates/gateway/src/proxy/generate.rs @@ -464,18 +464,25 @@ impl Outbound { }; // The model's reasoning setting, for the model named upstream, on - // the caller's request in its own format. + // the caller's request in its own format — except `off` on a hop + // converted for another format, which is applied to the conversion + // (see `effort::turn_off_converted`). + let converted = protocol.dialect() != client; + let off_converted = + converted && self.reasoning.setting == Some(effort::ReasoningSetting::Off); let mut source = std::borrow::Cow::Borrowed(&self.body); let mut setting_change = None; - if let Some(setting) = self.reasoning.setting { + let mut level_applied = false; + if let Some(setting) = self.reasoning.setting.filter(|_| !off_converted) { let mut body = self.body.clone(); if setting.apply(client, &mut body, model) { setting_change = self.reasoning.change(client, &body); + level_applied = true; source = std::borrow::Cow::Owned(body); } } - if protocol.dialect() == client { + if !converted { // Forwarded as sent. Only the model changes, and a Chat // stream always asks for its usage (see `hides_usage`). let mut body = source.into_owned(); @@ -542,6 +549,8 @@ impl Outbound { let mut decoded = decode(&source)?; decoded.request.model = model.to_string(); + let turned_off = + off_converted && effort::turn_off_converted(&mut decoded.request, protocol.dialect()); let prepared = decoded.encode(&target(protocol.dialect())); if !prepared.dropped.is_empty() { tracing::info!( @@ -552,10 +561,17 @@ impl Outbound { ); } let mut body = prepared.body; - if self.max_output_tokens.is_some() + if (self.max_output_tokens.is_some() || turned_off || level_applied) && let Ok(mut v) = serde_json::from_slice::(&body) { self.cap_output(protocol.dialect(), &mut v, model, official); + if turned_off { + tw_dialect::params::write_reasoning_none(protocol.dialect(), &mut v); + } + // Said against what the converted request really carries. + if turned_off || level_applied { + setting_change = self.reasoning.change(protocol.dialect(), &v); + } body = serde_json::to_vec(&v).unwrap_or(body); } // The conversion moved the placeholders along with the text; one it @@ -973,7 +989,8 @@ async fn run( // measured. The reasoning setting is applied to each hop too, for // the model named upstream, on the caller's request in its own format // (see `super::effort`): a hop converted for another format carries - // the changed effort the way that one writes it. + // the changed effort the way that one writes it. `off` on such a hop + // is applied to the conversion. let model_config = state.router.load().config_for(&mapped_model); let max_output_tokens = model_config.max_output_tokens; let reasoning = effort::Asked::of(model_config.reasoning, surface.dialect, &outbound_body); @@ -1567,6 +1584,166 @@ mod tests { assert!(sent(&wire).get("reasoning_effort").is_none()); } + /// `off` on a hop converted for another format: the converted request + /// says it the way the upstream's format does, and the log says what + /// it carries. + #[test] + fn off_reaches_a_hop_converted_for_another_format() { + let claude = serde_json::json!({ + "model": "gpt-5", "max_tokens": 2000, + "thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}, + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(MESSAGES, "/v1/messages", claude.clone(), "off"); + let wire = outbound + .address(UpstreamProtocol::OpenAiChat, "gpt-5", false) + .unwrap(); + assert_eq!(sent(&wire)["reasoning_effort"], "none"); + assert_eq!(wire.written_effort(), Some(WrittenEffort::Off)); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "high", "sent": "none", "reason": "setting"})) + ); + let wire = outbound + .address(UpstreamProtocol::OpenAiResponses, "gpt-5", false) + .unwrap(); + assert_eq!(sent(&wire)["reasoning"]["effort"], "none"); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "high", "sent": "none", "reason": "setting"})) + ); + // The same format, as before: the switch and the effort taken out. + let wire = outbound + .address(UpstreamProtocol::AnthropicMessages, "gpt-5", false) + .unwrap(); + assert!(sent(&wire).get("thinking").is_none(), "{}", sent(&wire)); + assert!(sent(&wire).get("output_config").is_none()); + + // Adaptive with no effort named converts to no effort at all: off + // still says `none`, with no asked level to say it against. + let mut adaptive = claude.clone(); + adaptive.as_object_mut().unwrap().remove("output_config"); + let outbound = with_setting(MESSAGES, "/v1/messages", adaptive, "off"); + let wire = outbound + .address(UpstreamProtocol::OpenAiChat, "gpt-5", false) + .unwrap(); + assert_eq!(sent(&wire)["reasoning_effort"], "none"); + assert_eq!(wire.effort, None); + + // Gemini 2.5 Flash thinks without a thinking config: a zero budget. + let outbound = with_setting(MESSAGES, "/v1/messages", claude, "off"); + let wire = outbound + .address(UpstreamProtocol::GoogleGenerate, "gemini-2.5-flash", false) + .unwrap(); + assert_eq!( + sent(&wire)["generationConfig"]["thinkingConfig"], + serde_json::json!({"thinkingBudget": 0}) + ); + + // A Chat caller to Anthropic: no thinking. + let chat = serde_json::json!({ + "model": "claude-opus-4-6", "reasoning_effort": "high", + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(CHAT, "/v1/chat/completions", chat, "off"); + let wire = outbound + .address( + UpstreamProtocol::AnthropicMessages, + "claude-opus-4-6", + false, + ) + .unwrap(); + assert!(sent(&wire).get("thinking").is_none(), "{}", sent(&wire)); + assert!(sent(&wire).get("output_config").is_none()); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "high", "sent": null, "reason": "setting"})) + ); + + // No reasoning: nothing turned off, nothing written. + let plain = serde_json::json!({ + "model": "gpt-5", "max_tokens": 50, + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(MESSAGES, "/v1/messages", plain, "off"); + let wire = outbound + .address(UpstreamProtocol::OpenAiChat, "gpt-5", false) + .unwrap(); + assert!(sent(&wire).get("reasoning_effort").is_none()); + assert_eq!(wire.effort, None); + } + + /// A level on a converted hop is written the way the upstream's format + /// does, and the log says what went out. + #[test] + fn a_level_on_a_converted_hop_is_said_as_sent() { + let claude = serde_json::json!({ + "model": "gpt-5", "max_tokens": 2000, + "thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}, + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(MESSAGES, "/v1/messages", claude, "high"); + let wire = outbound + .address(UpstreamProtocol::OpenAiChat, "gpt-5", false) + .unwrap(); + assert_eq!(sent(&wire)["reasoning_effort"], "high"); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "low", "sent": "high", "reason": "setting"})) + ); + let wire = outbound + .address(UpstreamProtocol::OpenAiResponses, "gpt-5", false) + .unwrap(); + assert_eq!(sent(&wire)["reasoning"]["effort"], "high"); + let chat = serde_json::json!({ + "model": "claude-opus-4-6", "reasoning_effort": "low", + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(CHAT, "/v1/chat/completions", chat, "high"); + let wire = outbound + .address( + UpstreamProtocol::AnthropicMessages, + "claude-opus-4-6", + false, + ) + .unwrap(); + assert_eq!(sent(&wire)["output_config"]["effort"], "high"); + assert_eq!(sent(&wire)["thinking"]["type"], "adaptive"); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "low", "sent": "high", "reason": "setting"})) + ); + } + + /// A converted `none` the upstream refuses goes out again with the + /// lowest level it named. + #[test] + fn a_refused_none_on_a_converted_hop_takes_the_lowest_level() { + let claude = serde_json::json!({ + "model": "gpt-5", "max_tokens": 2000, + "thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}, + "messages": [{"role": "user", "content": "ping"}] + }); + let outbound = with_setting(MESSAGES, "/v1/messages", claude, "off"); + let mut wire = outbound + .address(UpstreamProtocol::OpenAiChat, "gpt-5", false) + .unwrap(); + let refused = GatewayError::ProviderHttpError { + status: 400, + message: "relay: Unsupported value: 'none' is not supported with this model for reasoning_effort. Supported values are: 'minimal', 'low', 'medium', and 'high'.".into(), + retry_after: None, + }; + let sent_effort = wire.written_effort().unwrap(); + let fix = effort::rejection(&refused, "relay", sent_effort).unwrap(); + assert_eq!(fix, EffortFix::Use(Effort::Minimal)); + assert!(wire.fix_effort(fix, sent_effort, Reason::Rejected)); + assert_eq!(sent(&wire)["reasoning_effort"], "minimal"); + assert_eq!( + wire.effort.map(|c| c.detail()), + Some(serde_json::json!({"asked": "none", "sent": "minimal", "reason": "rejected"})) + ); + } + #[test] fn a_refused_effort_is_sent_again_as_the_upstream_says() { let ask = serde_json::json!({ diff --git a/crates/test-support/tests/gateway_proxy.rs b/crates/test-support/tests/gateway_proxy.rs index 1ab61029..ae4a92e1 100644 --- a/crates/test-support/tests/gateway_proxy.rs +++ b/crates/test-support/tests/gateway_proxy.rs @@ -668,7 +668,8 @@ async fn an_upstream_that_refuses_added_cache_breakpoints_gets_the_request_witho /// A Chat upstream that takes reasoning efforts up to `high`: a request /// asking for more is refused with a 400 naming the ones it takes, the -/// way OpenAI words it. It does not take `none` either. +/// way OpenAI words it. It does not take `none` either, and names the same +/// ones. async fn chat_upstream_up_to_high() -> MockProvider { use wiremock::matchers::{method, path}; use wiremock::{Mock, Request, ResponseTemplate}; @@ -682,13 +683,7 @@ async fn chat_upstream_up_to_high() -> MockProvider { .respond_with(|req: &Request| { let body: Value = serde_json::from_slice(&req.body).unwrap_or_default(); let effort = body["reasoning_effort"].as_str().unwrap_or(""); - if effort == "none" { - return ResponseTemplate::new(400).set_body_json(json!({"error": { - "message": "Invalid value for 'reasoning_effort': 'none'.", - "type": "invalid_request_error", - }})); - } - if matches!(effort, "xhigh" | "max") { + if matches!(effort, "none" | "xhigh" | "max") { return ResponseTemplate::new(400).set_body_json(json!({"error": { "message": format!("Unsupported value: '{effort}' is not supported with this model. Supported values are: 'low', 'medium', and 'high'."), "type": "invalid_request_error", @@ -918,7 +913,8 @@ async fn an_effort_refused_twice_is_the_answer() { /// The model's setting: a level changes requests that have reasoning on /// and leaves the rest alone; `off` writes OpenAI's `none`, and an upstream -/// that does not take it gets the request without the field — remembered. +/// that does not take it gets the request with the lowest level it names — +/// remembered. #[ignore = "integration test — run via `make test-it`"] #[tokio::test] async fn the_models_reasoning_setting_rewrites_requests_with_reasoning_on() { @@ -961,6 +957,33 @@ async fn the_models_reasoning_setting_rewrites_requests_with_reasoning_on() { } assert_eq!( efforts_sent(&upstream).await[2..], - [Some("none".into()), None, None] + [Some("none".into()), Some("low".into()), Some("low".into())] + ); +} + +/// `off` on a request converted for an upstream of another format: an +/// Anthropic caller's thinking reaches an OpenAI Chat upstream as `none`, +/// not as no effort at all (the model's default), and a refused `none` +/// goes out again with the lowest level the upstream names. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn off_reaches_an_upstream_of_another_format() { + let app = TestApp::spawn().await; + let upstream = chat_upstream_up_to_high().await; + let (_, key) = seed_reasoning(&app, &upstream.uri(), "effort-off-converted", Some("off")).await; + let gw = app.gateway_client(); + gw.set_bearer(&key); + gw.post( + "/v1/messages", + json!({"model": "effort-off-converted", "max_tokens": 2000, + "thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}, + "messages": [{"role": "user", "content": "ping"}]}), + ) + .await + .unwrap() + .assert_ok(); + assert_eq!( + efforts_sent(&upstream).await, + [Some("none".into()), Some("low".into())] ); }