From 9d5abe2f980fbd0983d5f2a84b232cb8fb4fea7a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:09:37 +0800 Subject: [PATCH] 0.83.1: reasoning off picks the lowest accepted level and survives format conversion - tw-dialect: add think::none_fix. A refused `none` is resent with the lowest level the error lists, and without the effort only when it lists none; dropping it made the model reason at its default level. none_rejection is unchanged. - tw-dialect: add params::write_reasoning_none, which writes OpenAI's `none` even where the body carries no effort yet. - Converted hops: `set.reasoning: off` now reaches the upstream. OpenAI Chat and Responses targets get `none` written on the converted body; Gemini targets get reasoning marked as disabled, so the encoder writes a zero thinking budget on 2.5 Flash and reports it dropped elsewhere. The attempt chain and the effort memory read what was sent. - Docs for set.reasoning and the resend; version 0.83.1 with release notes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 ++--- Cargo.toml | 2 +- crates/tw-dialect/src/params.rs | 64 ++++++- crates/tw-dialect/src/think.rs | 59 +++++- crates/tw-gateway/src/effort_memory.rs | 2 +- crates/tw-gateway/src/server/pipeline/hop.rs | 12 +- crates/tw-gateway/src/translate.rs | 45 ++++- crates/tw-gateway/tests/effort.rs | 192 ++++++++++++++++++- docs/config.md | 8 +- docs/config.zh-CN.md | 4 +- release-notes/0.83.1.md | 16 ++ 11 files changed, 400 insertions(+), 48 deletions(-) create mode 100644 release-notes/0.83.1.md diff --git a/Cargo.lock b/Cargo.lock index 49743cf0..90f375da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3670,7 +3670,7 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.83.0" +version = "0.83.1" dependencies = [ "serde", "serde_json", @@ -3681,7 +3681,7 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.83.0" +version = "0.83.1" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -3702,7 +3702,7 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.83.0" +version = "0.83.1" dependencies = [ "serde", "serde_json", @@ -3710,7 +3710,7 @@ dependencies = [ [[package]] name = "tw-config" -version = "0.83.0" +version = "0.83.1" dependencies = [ "blake3", "libc", @@ -3736,7 +3736,7 @@ dependencies = [ [[package]] name = "tw-control" -version = "0.83.0" +version = "0.83.1" dependencies = [ "axum", "base64 0.22.1", @@ -3782,7 +3782,7 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.83.0" +version = "0.83.1" dependencies = [ "memchr", "serde", @@ -3791,7 +3791,7 @@ dependencies = [ [[package]] name = "tw-engine" -version = "0.83.0" +version = "0.83.1" dependencies = [ "chrono", "serde", @@ -3805,7 +3805,7 @@ dependencies = [ [[package]] name = "tw-gateway" -version = "0.83.0" +version = "0.83.1" dependencies = [ "arc-swap", "async-stream", @@ -3856,7 +3856,7 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.83.0" +version = "0.83.1" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -3874,7 +3874,7 @@ dependencies = [ [[package]] name = "tw-handover" -version = "0.83.0" +version = "0.83.1" dependencies = [ "libc", "serde", @@ -3887,7 +3887,7 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.83.0" +version = "0.83.1" dependencies = [ "serde", "serde_json", @@ -3901,7 +3901,7 @@ dependencies = [ [[package]] name = "tw-observe" -version = "0.83.0" +version = "0.83.1" dependencies = [ "tokio", "tracing", @@ -3910,7 +3910,7 @@ dependencies = [ [[package]] name = "tw-pack" -version = "0.83.0" +version = "0.83.1" dependencies = [ "age", "blake3", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "tw-plugin" -version = "0.83.0" +version = "0.83.1" dependencies = [ "libc", "rand 0.10.2", @@ -3950,7 +3950,7 @@ dependencies = [ [[package]] name = "tw-pricing" -version = "0.83.0" +version = "0.83.1" dependencies = [ "arc-swap", "flate2", @@ -3963,14 +3963,14 @@ dependencies = [ [[package]] name = "tw-secret" -version = "0.83.0" +version = "0.83.1" dependencies = [ "thiserror 2.0.20", ] [[package]] name = "tw-store" -version = "0.83.0" +version = "0.83.1" dependencies = [ "blake3", "bytes", @@ -3990,7 +3990,7 @@ dependencies = [ [[package]] name = "tw-sync" -version = "0.83.0" +version = "0.83.1" dependencies = [ "age", "axum", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.83.0" +version = "0.83.1" dependencies = [ "serde", "serde_json", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.83.0" +version = "0.83.1" dependencies = [ "libc", "notify", @@ -4038,7 +4038,7 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.83.0" +version = "0.83.1" dependencies = [ "saphyr-parser", "serde_yaml_ng", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "twcore" -version = "0.83.0" +version = "0.83.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index ebb6a463..2e03a2ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 -version = "0.83.0" +version = "0.83.1" [workspace.dependencies] # ── 内部 crate ─────────────────────────────────────────── diff --git a/crates/tw-dialect/src/params.rs b/crates/tw-dialect/src/params.rs index f94cf1ad..7310b1ad 100644 --- a/crates/tw-dialect/src/params.rs +++ b/crates/tw-dialect/src/params.rs @@ -199,8 +199,10 @@ pub fn set_model(dialect: Dialect, body: &mut Value, model: &str) { /// **OpenAI 的两种格式写 `none`,不是去掉**:GPT-5 这类模型没写强度时按默认的那一档想,去掉 /// 等于没关。Chat 的 `reasoning_effort`、Responses 的 `reasoning.effort`(连同对话中途的 /// `configuration_update`)写成 `none`;Chat 里 DeepSeek、GLM、Kimi 那种 `thinking.type: enabled` -/// 的开关整个去掉,连同强度。上游不认 `none` 的由网关去掉再发(见 -/// [`think::none_rejection`]) +/// 的开关整个去掉,连同强度。上游不认 `none` 的由网关改成它认的最低一档再发,没说认哪几档的去掉 +/// 再发(见 [`think::none_fix`])。 +/// +/// 转换过格式的请求在转换出来的那一份上写 `none`,见 [`write_reasoning_none`] pub fn disable_thinking(dialect: Dialect, body: &mut Value) { let Some(obj) = body.as_object_mut() else { return; @@ -255,6 +257,37 @@ pub fn disable_thinking(dialect: Dialect, body: &mut Value) { } } +/// 写明 OpenAI 的 `none`(关掉推理):Chat 的 `reasoning_effort`、Responses 的 `reasoning.effort` +/// (连同对话中途的 `configuration_update`),没写的补上。别的格式没有 `none`,什么都不做。返回 +/// 改没改。 +/// +/// 给转换过格式的请求用:客户端那一份开着推理、要关掉时,去掉推理配置之后转换出来的那一份不带 +/// 强度,GPT-5 这类模型没写强度时按默认的那一档想,等于没关。同格式的请求用 [`disable_thinking`] +pub fn write_reasoning_none(dialect: Dialect, body: &mut Value) -> bool { + let Some(obj) = body.as_object_mut() else { + return false; + }; + match dialect { + Dialect::Chat => put(obj, "reasoning_effort", "none"), + Dialect::Responses => { + let mut changed = match obj.get_mut("reasoning") { + Some(Value::Object(r)) => put(r, "effort", "none"), + _ => { + obj.insert("reasoning".into(), serde_json::json!({ "effort": "none" })); + true + } + }; + for r in responses_updates(obj) { + if r.contains_key("effort") { + changed |= put(r, "effort", "none"); + } + } + changed + } + Dialect::Anthropic | Dialect::Gemini | Dialect::Bedrock => false, + } +} + /// 去掉 `output_config.effort`;`output_config` 去空了的整个去掉。返回去没去掉 fn remove_output_effort(holder: &mut Map) -> bool { let Some(o) = holder @@ -1222,6 +1255,33 @@ mod tests { ); } + /// 转换出来的那一份:没写强度的补上 `none` + #[test] + fn writing_none_fills_in_the_openai_effort() { + let mut v = json!({"model": "m"}); + assert!(write_reasoning_none(Dialect::Chat, &mut v)); + assert_eq!(v, json!({"model": "m", "reasoning_effort": "none"})); + assert!(!write_reasoning_none(Dialect::Chat, &mut v)); + let mut v = json!({"reasoning_effort": "high"}); + assert!(write_reasoning_none(Dialect::Chat, &mut v)); + assert_eq!(written_effort(Dialect::Chat, &v), Some(WrittenEffort::Off)); + let mut v = json!({"model": "m"}); + assert!(write_reasoning_none(Dialect::Responses, &mut v)); + assert_eq!(v, json!({"model": "m", "reasoning": {"effort": "none"}})); + let mut v = json!({"reasoning": {"effort": "high", "summary": "auto"}}); + assert!(write_reasoning_none(Dialect::Responses, &mut v)); + assert_eq!( + v, + json!({"reasoning": {"effort": "none", "summary": "auto"}}) + ); + assert!(!reasoning_on(Dialect::Responses, &v)); + for d in [Dialect::Anthropic, Dialect::Gemini, Dialect::Bedrock] { + let mut v = json!({"model": "m"}); + assert!(!write_reasoning_none(d, &mut v)); + assert_eq!(v, json!({"model": "m"})); + } + } + #[test] fn turning_thinking_off_takes_the_effort_with_it() { // 自适应的 Claude 只去掉开关的话,照样按 `output_config.effort` 那一档想 diff --git a/crates/tw-dialect/src/think.rs b/crates/tw-dialect/src/think.rs index 67378f53..60604293 100644 --- a/crates/tw-dialect/src/think.rs +++ b/crates/tw-dialect/src/think.rs @@ -229,13 +229,30 @@ pub fn effort_rejection(status: u16, error_body: &str, sent: Effort) -> Option bool { let text = error_body.to_lowercase(); about_effort(status, &text, "none", &levels_in(&text)) } +/// 发出去的 `none` 被上游拒了([`none_rejection`])之后,重发时怎么写:原话里列了档位的,用其中 +/// 最低的那一档 —— 要的是少想,去掉强度的话模型按默认的那一档(通常是 medium)想,反倒想得更多; +/// 一档都没说的,去掉强度。不是因为 `none` 的是 `None` +pub fn none_fix(status: u16, error_body: &str) -> Option { + if !none_rejection(status, error_body) { + return None; + } + let lowest = levels_in(&error_body.to_lowercase()) + .into_iter() + .map(|(_, e)| e) + .min(); + Some(match lowest { + Some(e) => EffortFix::Use(e), + None => EffortFix::Drop, + }) +} + /// 见 [`effort_rejection`]。`text` 是小写的原话,`sent` 是发出去的那个词,`found` 是原话里的档位 fn about_effort(status: u16, text: &str, sent: &str, found: &[(usize, Effort)]) -> bool { if status != 400 @@ -460,6 +477,44 @@ mod tests { assert!(!none_rejection(400, "messages: field required")); } + #[test] + fn a_refused_none_takes_the_lowest_level_named() { + use Effort::*; + assert_eq!( + none_fix( + 400, + "Unsupported value: 'none' is not supported with this model for reasoning.effort. Supported values are: 'minimal', 'low', 'medium', and 'high'." + ), + Some(EffortFix::Use(Minimal)) + ); + assert_eq!( + none_fix( + 400, + "Unsupported value: 'none' is not supported with this model for reasoning.effort. Supported values are: 'low', 'medium', and 'high'." + ), + Some(EffortFix::Use(Low)) + ); + assert_eq!( + none_fix( + 400, + "该模型始终思考,不支持关闭思考;请使用 low、high 或 max。" + ), + Some(EffortFix::Use(Low)) + ); + // 没列档位的:去掉强度 + assert_eq!( + none_fix(400, "reasoning_effort none is not supported by this model"), + Some(EffortFix::Drop) + ); + // 不是因为 `none` 的 + assert_eq!(none_fix(400, "Unsupported value: 'none'"), None); + assert_eq!( + none_fix(500, "reasoning_effort none is not supported"), + None + ); + assert_eq!(none_fix(400, "messages: field required"), None); + } + #[test] fn openai_none_turns_reasoning_off() { assert_eq!(parse_openai("none"), Some(None)); diff --git a/crates/tw-gateway/src/effort_memory.rs b/crates/tw-gateway/src/effort_memory.rs index 8a8d9340..cbc9a626 100644 --- a/crates/tw-gateway/src/effort_memory.rs +++ b/crates/tw-gateway/src/effort_memory.rs @@ -3,7 +3,7 @@ //! 各家认的档位不一样:Claude 的有的模型没有 `xhigh`,关着思考的不收 `high` 以上,GLM 这类 //! 始终思考的模型不收 `medium`,有的 OpenAI 兼容接口不收 `none`。请求体里写明的那一档(见 //! [`tw_dialect::params::written_effort`],客户端写的或者路由规则改的)被上游拒了(400,原话说的是 -//! 推理强度,见 [`tw_dialect::think::effort_rejection`]、[`tw_dialect::think::none_rejection`]), +//! 推理强度,见 [`tw_dialect::think::effort_rejection`]、[`tw_dialect::think::none_fix`]), //! 同一家改一档、或者去掉强度马上再发一次。**重发成了才记**:(上游,发给它的模型名,发出去的 //! 格式)不认哪一档、改用什么。往后发给它的请求要是还写着那一档,发之前直接改,不用每次都先被 //! 拒一次。重发又被拒了的不记;记着的改法自己被拒了、重发成了用的正是原来那一档的,那一条删掉。 diff --git a/crates/tw-gateway/src/server/pipeline/hop.rs b/crates/tw-gateway/src/server/pipeline/hop.rs index cce2b940..361c1b81 100644 --- a/crates/tw-gateway/src/server/pipeline/hop.rs +++ b/crates/tw-gateway/src/server/pipeline/hop.rs @@ -2177,7 +2177,7 @@ fn prepare( } }; let mut d = d.clone(); - crate::translate::apply_set(&mut d.request, effective_set); + let reasoning_off = crate::translate::apply_set(&mut d.request, effective_set, dialect); // Codex 后端不认输出上限:带着它发过去是一个 400 let limit = if chatgpt { crate::chatgpt::drop_output_limit(&mut d.request, d.client) @@ -2214,6 +2214,10 @@ fn prepare( // 客户端一个断点都没标:请求体里的断点是编码器自动标的。这一家的这个模型拒过的话 // 先去掉,不用再被拒一次(见 `crate::cache_marks`) let mut encoded = p.body.clone(); + // 规则关掉了推理:OpenAI 的两种格式写明 `none`(见 `translate::reasoning_off`) + if reasoning_off { + encoded = crate::translate::reasoning_off(dialect, encoded); + } if d.request.cache.is_empty() && tw_dialect::cache::may_have_marks(dialect, &encoded) { let model = &d.request.model; if !state.cache_marks.refused(&provider.name, model) { @@ -2396,7 +2400,7 @@ fn apply_fix(wire: tw_dialect::ir::Dialect, v: &mut serde_json::Value, fix: Effo } /// 上游回的这个 400 是不是因为写明的推理强度([`tw_dialect::think::effort_rejection`],发出去的 -/// 是 `none` 的看 [`tw_dialect::think::none_rejection`],那时去掉它):是的话,交回发出去的格式、 +/// 是 `none` 的看 [`tw_dialect::think::none_fix`]):是的话,交回发出去的格式、 /// 记的时候按哪一档记(这一跳改强度之前写明的那一档;没算过的就是发出去的那一档)和重发时怎么 /// 改。只写了预算的不算;照着改了也和发出去的一样的不算:重发一遍还是被拒 fn effort_refused( @@ -2411,9 +2415,7 @@ fn effort_refused( let said = String::from_utf8_lossy(said); let fix = match sent { WrittenEffort::Level(e) => tw_dialect::think::effort_rejection(400, &said, e)?, - WrittenEffort::Off => { - tw_dialect::think::none_rejection(400, &said).then_some(EffortFix::Drop)? - } + WrittenEffort::Off => tw_dialect::think::none_fix(400, &said)?, }; let mut tried = v; apply_fix(wire, &mut tried, fix).then_some((wire, hop.pre.unwrap_or(sent), fix)) diff --git a/crates/tw-gateway/src/translate.rs b/crates/tw-gateway/src/translate.rs index 70afc575..93116ca7 100644 --- a/crates/tw-gateway/src/translate.rs +++ b/crates/tw-gateway/src/translate.rs @@ -49,24 +49,59 @@ pub fn required_headers(target: Dialect) -> &'static [(&'static str, &'static st } } -/// 规则里的参数改写,改在中间表示上:转换出去的请求四种格式一样生效。 -pub fn apply_set(r: &mut Request, set: &tw_engine::SetAction) { +/// 规则里的参数改写,改在中间表示上:转换出去的请求四种格式一样生效。`target` 是要转成的格式。 +/// +/// 返回推理是不是这里关掉的:是的话,转成 OpenAI 的两种格式之后还要在转换出来的那一份上写明 +/// `none`([`reasoning_off`]) +pub fn apply_set(r: &mut Request, set: &tw_engine::SetAction, target: Dialect) -> bool { if let Some(m) = &set.model { r.model = m.clone(); } if let Some(t) = set.max_tokens { r.max_tokens = Some(t); } - // 和直通时一样只改开着推理的请求:关掉是去掉推理配置;改一档是改强度,预算跟着强度 - // 由编码器折算(客户端写的预算不再作数) + // 和直通时一样只改开着推理的请求:改一档是改强度,预算跟着强度由编码器折算(客户端写的 + // 预算不再作数),写成目标格式的那种写法 match (set.reasoning, r.reasoning.as_mut()) { - (Some(tw_engine::SetReasoning::Off), Some(re)) if re.enabled => r.reasoning = None, + (Some(tw_engine::SetReasoning::Off), Some(re)) if re.enabled => { + // 关掉是去掉推理配置。Gemini 2.5 不写思考配置时照样想:写成明确关掉,编码器在关得掉的 + // 模型上写预算 0,关不掉的照实报丢了。OpenAI 的两种格式没写强度时按默认的那一档想, + // 由调用方补上 `none` + if target == Dialect::Gemini { + *re = tw_dialect::ir::Reasoning { + enabled: false, + effort: None, + budget: None, + summary: false, + }; + } else { + r.reasoning = None; + } + return true; + } (Some(tw_engine::SetReasoning::Level(e)), Some(re)) if re.enabled => { re.effort = Some(e); re.budget = None; } _ => {} } + false +} + +/// 规则关掉了推理([`apply_set`])、转换出来的这一份(`target` 格式):OpenAI 的两种格式写明 +/// `none`([`tw_dialect::params::write_reasoning_none`]),别的格式原样交回。不写的话转换出来的这 +/// 一份不带强度,GPT-5 这类模型按默认的那一档想,等于没关 +pub fn reasoning_off(target: Dialect, body: Vec) -> Vec { + if !matches!(target, Dialect::Chat | Dialect::Responses) { + return body; + } + let Ok(mut v) = serde_json::from_slice::(&body) else { + return body; + }; + if !tw_dialect::params::write_reasoning_none(target, &mut v) { + return body; + } + serde_json::to_vec(&v).unwrap_or(body) } /// 客户端没写最大输出、目标格式又必须写(Anthropic)时用多少。 diff --git a/crates/tw-gateway/tests/effort.rs b/crates/tw-gateway/tests/effort.rs index 90985cff..aff00982 100644 --- a/crates/tw-gateway/tests/effort.rs +++ b/crates/tw-gateway/tests/effort.rs @@ -614,9 +614,10 @@ routes: } /// `off` 在 OpenAI 的格式里写 `none`(去掉的话 GPT-5 这类按默认的那一档想);上游不认 `none` -/// 的去掉再发,记下来,往后直接去掉 +/// 的改用它列出的最低一档再发(去掉的话按默认的 medium 想,反倒想得更多),记下来,往后直接发 +/// 那一档。一档都没列的去掉再发 #[tokio::test] -async fn off_writes_none_and_an_upstream_that_refuses_it_gets_the_field_removed() { +async fn off_writes_none_and_an_upstream_that_refuses_it_gets_its_lowest_level() { let refusal = json!({"error": {"message": "Unsupported value: 'none' is not supported with this model for reasoning_effort. Supported values are: 'low', 'medium', and 'high'.", "type": "invalid_request_error", "param": "reasoning_effort", "code": "unsupported_value"}}); let (up, seen) = upstream(vec![(400, refusal), (200, chat_ok())]).await; @@ -631,7 +632,7 @@ async fn off_writes_none_and_an_upstream_that_refuses_it_gets_the_field_removed( { let seen = seen.lock().unwrap(); assert_eq!(seen[0]["reasoning_effort"], "none"); - assert!(seen[1].get("reasoning_effort").is_none(), "{}", seen[1]); + assert_eq!(seen[1]["reasoning_effort"], "low"); } let chain = attempts(&mut gw.rx).await; assert_eq!( @@ -640,12 +641,191 @@ async fn off_writes_none_and_an_upstream_that_refuses_it_gets_the_field_removed( ); assert_eq!( chain[1].effort, - change("none", None, tw_api::EffortReason::Rejected) + change("none", Some("low"), tw_api::EffortReason::Rejected) ); post(&gw, "/v1/chat/completions", ask).await; - assert!(seen.lock().unwrap()[2].get("reasoning_effort").is_none()); + assert_eq!(seen.lock().unwrap()[2]["reasoning_effort"], "low"); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("none", Some("low"), tw_api::EffortReason::Remembered) + ); + + // 没列档位的:去掉再发 + let refusal = json!({"error": {"message": "reasoning_effort 'none' is not supported by this model", + "type": "invalid_request_error"}}); + let (up, seen) = upstream(vec![(400, refusal), (200, chat_ok())]).await; + let mut gw = gateway(config(up, "openai-chat", rules), None).await; + let ask = json!({"model": "o3-mini", "reasoning_effort": "high", + "messages": [{"role": "user", "content": "hi"}]}); + let (status, body) = post(&gw, "/v1/chat/completions", ask).await; + assert_eq!(status, 200, "{body}"); + assert!(seen.lock().unwrap()[1].get("reasoning_effort").is_none()); + assert_eq!( + attempts(&mut gw.rx).await[1].effort, + change("none", None, tw_api::EffortReason::Rejected) + ); +} + +fn responses_ok() -> Value { + json!({"id": "resp_1", "object": "response", "status": "completed", "model": "gpt-5", + "output": [{"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "好"}]}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}}) +} + +fn gemini_ok() -> Value { + json!({"candidates": [{"content": {"role": "model", "parts": [{"text": "好"}]}, + "finishReason": "STOP", "index": 0}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}}) +} + +const OFF: &str = " - { name: 别想, set: { reasoning: off } } + - { name: 兜底, to: relay } +"; + +const HIGH: &str = " - { name: 想多点, set: { reasoning: high } } + - { name: 兜底, to: relay } +"; + +/// 跨格式的 `off`:Anthropic 的客户端发给 OpenAI Chat 的上游,转换出来的那一份写明 `none`。只去掉 +/// 推理配置的话转换出来的不带强度,上游按默认的那一档想。尝试链记的是真正发出去的 `none`; +/// 上游不认 `none` 的照样改用最低一档再发 +#[tokio::test] +async fn off_across_a_conversion_writes_none_in_the_upstream_format() { + let refusal = json!({"error": {"message": "Unsupported value: 'none' is not supported with this model for reasoning_effort. Supported values are: 'minimal', 'low', 'medium', and 'high'.", + "type": "invalid_request_error", "param": "reasoning_effort", "code": "unsupported_value"}}); + let (up, seen) = upstream(vec![(200, chat_ok()), (400, refusal), (200, chat_ok())]).await; + let mut gw = gateway(config(up, "openai-chat", OFF), None).await; + let mut ask = claude("high"); + ask["model"] = json!("gpt-5"); + let (status, body) = post(&gw, "/v1/messages", ask.clone()).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(seen.lock().unwrap()[0]["reasoning_effort"], "none"); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("high", Some("none"), tw_api::EffortReason::Rule) + ); + // 自适应、没写强度的也关:转换出来的那一份本来就不带强度 + let mut adaptive = ask.clone(); + adaptive.as_object_mut().unwrap().remove("output_config"); + post(&gw, "/v1/messages", adaptive).await; + { + let seen = seen.lock().unwrap(); + assert_eq!(seen[1]["reasoning_effort"], "none"); + assert_eq!(seen[2]["reasoning_effort"], "minimal"); + } + let chain = attempts(&mut gw.rx).await; + assert_eq!( + chain[1].effort, + change("none", Some("minimal"), tw_api::EffortReason::Rejected) + ); +} + +/// 跨格式的 `off`,发给 Responses 的上游:`reasoning.effort` 写 `none` +#[tokio::test] +async fn off_across_a_conversion_to_responses_writes_none() { + let (up, seen) = upstream(vec![(200, responses_ok())]).await; + let mut gw = gateway(config(up, "openai-responses", OFF), None).await; + let mut ask = claude("high"); + ask["model"] = json!("gpt-5"); + let (status, body) = post(&gw, "/v1/messages", ask).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + seen.lock().unwrap()[0].pointer("/reasoning/effort"), + Some(&json!("none")) + ); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("high", Some("none"), tw_api::EffortReason::Rule) + ); +} + +/// 跨格式的 `off`,发给 Anthropic 的上游:不带思考配置和强度 +#[tokio::test] +async fn off_across_a_conversion_to_anthropic_sends_no_thinking() { + let (up, seen) = upstream(vec![(200, anthropic_ok())]).await; + let mut gw = gateway(config(up, "anthropic", OFF), None).await; + let (status, body) = post( + &gw, + "/v1/chat/completions", + json!({"model": "claude-opus-4-6", "reasoning_effort": "high", + "messages": [{"role": "user", "content": "hi"}]}), + ) + .await; + assert_eq!(status, 200, "{body}"); + { + let seen = seen.lock().unwrap(); + assert!(seen[0].get("thinking").is_none(), "{}", seen[0]); + assert!(seen[0].get("output_config").is_none(), "{}", seen[0]); + } + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("high", None, tw_api::EffortReason::Rule) + ); +} + +/// 跨格式的 `off`,发给 Gemini 2.5 Flash:不写思考配置时它照样想,写明预算 0 +#[tokio::test] +async fn off_across_a_conversion_to_gemini_flash_writes_a_zero_budget() { + let (up, seen) = upstream(vec![(200, gemini_ok())]).await; + let mut gw = gateway(config(up, "gemini", OFF), None).await; + let mut ask = claude("high"); + ask["model"] = json!("gemini-2.5-flash"); + let (status, body) = post(&gw, "/v1/messages", ask).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + seen.lock().unwrap()[0].pointer("/generationConfig/thinkingConfig"), + Some(&json!({"thinkingBudget": 0})) + ); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("high", None, tw_api::EffortReason::Rule) + ); +} + +/// 跨格式改一档:写成上游那种格式的写法,尝试链记的是发出去的那一档 +#[tokio::test] +async fn a_level_across_a_conversion_is_written_in_the_upstream_format() { + let mut ask = claude("low"); + ask["model"] = json!("gpt-5"); + + let (up, seen) = upstream(vec![(200, chat_ok())]).await; + let mut gw = gateway(config(up, "openai-chat", HIGH), None).await; + post(&gw, "/v1/messages", ask.clone()).await; + assert_eq!(seen.lock().unwrap()[0]["reasoning_effort"], "high"); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("low", Some("high"), tw_api::EffortReason::Rule) + ); + + let (up, seen) = upstream(vec![(200, responses_ok())]).await; + let mut gw = gateway(config(up, "openai-responses", HIGH), None).await; + post(&gw, "/v1/messages", ask).await; + assert_eq!( + seen.lock().unwrap()[0].pointer("/reasoning/effort"), + Some(&json!("high")) + ); + assert_eq!( + attempts(&mut gw.rx).await[0].effort, + change("low", Some("high"), tw_api::EffortReason::Rule) + ); + + let (up, seen) = upstream(vec![(200, anthropic_ok())]).await; + let mut gw = gateway(config(up, "anthropic", HIGH), None).await; + post( + &gw, + "/v1/chat/completions", + json!({"model": "claude-opus-4-6", "reasoning_effort": "low", + "messages": [{"role": "user", "content": "hi"}]}), + ) + .await; + { + let seen = seen.lock().unwrap(); + assert_eq!(effort_of(&seen[0]), Some("high")); + assert_eq!(seen[0]["thinking"]["type"], "adaptive"); + } assert_eq!( attempts(&mut gw.rx).await[0].effort, - change("none", None, tw_api::EffortReason::Remembered) + change("low", Some("high"), tw_api::EffortReason::Rule) ); } diff --git a/docs/config.md b/docs/config.md index 267f2ba7..a4c5f634 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1502,12 +1502,16 @@ it. Answers carry the name the client asked for, as with aliases. `set.reasoning` writes the effort where the client's format keeps it (`output_config.effort`, `reasoning_effort`, `reasoning.effort`, Gemini's -`thinkingLevel`, or the thinking budget when the client gave a budget). +`thinkingLevel`, or the thinking budget when the client gave a budget). A +request converted for an upstream of another format carries it the way that +format writes it: `off` sent to an OpenAI Chat or Responses upstream is +`none` there, and sent to Gemini 2.5 Flash it is a thinking budget of 0. When an upstream answers 400 because of the reasoning effort, whether the client or a rule chose it, the request is sent once more to the same upstream with a level the upstream named (the nearest one, or the ceiling it gave), or -without an effort when it named none. The resend is a new attempt in the +without an effort when it named none. A refused `none` is replaced by the +lowest level the upstream named, so reasoning stays as low as it allows. The resend is a new attempt in the request details and does not count as a retry. If it succeeds, the level is remembered for that upstream and model for 7 days in `effort-memory.json` in the data directory, so later requests send the working level directly. The diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 87bc681c..001b4880 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -1089,9 +1089,9 @@ routes: `set.model` 可以写别名,每家上游收到各自对它的叫法。和别名一样,回答里的模型名写成客户端请求的名称。 -`set.reasoning` 按客户端的格式写推理强度:`output_config.effort`、`reasoning_effort`、`reasoning.effort`、Gemini 的 `thinkingLevel`;客户端给的是思考预算时改预算。 +`set.reasoning` 按客户端的格式写推理强度:`output_config.effort`、`reasoning_effort`、`reasoning.effort`、Gemini 的 `thinkingLevel`;客户端给的是思考预算时改预算。转换成另一种格式发给上游的请求按那种格式的写法写:`off` 发给 OpenAI Chat 或 Responses 上游时写 `none`,发给 Gemini 2.5 Flash 时写思考预算 0。 -上游因为推理强度回 400 时(无论这一档是客户端写的还是规则改的),请求在同一家上游再发一次:改用上游原话里列出的、离原来那一档最近的一档,原话给了上限的用上限,一档都没列的去掉推理强度。重发在请求详情里是新的一跳,不算重试。重发成功后,这一档按上游和模型记 7 天,存在数据目录的 `effort-memory.json` 里,之后的请求直接发可用的那一档。上游的地址、密钥或协议变了,或者上游被删除,它的记录随之作废。只给了思考预算的请求不重发。WebSocket 连接(Codex)上,一轮因推理强度被拒时不重发、也不记录,拒绝原样交给客户端。 +上游因为推理强度回 400 时(无论这一档是客户端写的还是规则改的),请求在同一家上游再发一次:改用上游原话里列出的、离原来那一档最近的一档,原话给了上限的用上限,一档都没列的去掉推理强度。被拒的是 `none` 时改用原话里列出的最低一档,推理保持在上游允许的最低。重发在请求详情里是新的一跳,不算重试。重发成功后,这一档按上游和模型记 7 天,存在数据目录的 `effort-memory.json` 里,之后的请求直接发可用的那一档。上游的地址、密钥或协议变了,或者上游被删除,它的记录随之作废。只给了思考预算的请求不重发。WebSocket 连接(Codex)上,一轮因推理强度被拒时不重发、也不记录,拒绝原样交给客户端。 diff --git a/release-notes/0.83.1.md b/release-notes/0.83.1.md new file mode 100644 index 00000000..8b26edf4 --- /dev/null +++ b/release-notes/0.83.1.md @@ -0,0 +1,16 @@ +This release fixes two cases where `set.reasoning: off` in a routing rule left the model reasoning. + +**Upgrade notes** + +- The control-plane protocol version stays at 54; ThinkWatch Lite releases that include 0.83.0 connect to 0.83.1 and the other way round. +- The request store's schema and the configuration are unchanged; nothing is cleared. +- No new message codes. + +**A refused `none` is resent with the lowest level.** When an upstream answered 400 to the `none` that `off` writes in the OpenAI formats (for models that accept only `minimal`, `low`, `medium` or `high`), the request was resent without the effort, so the model reasoned at its default level, usually `medium`, and that was remembered for 7 days. The resend now uses the lowest level the error lists, such as `minimal` or `low`, and only leaves the effort out when the error lists none. Entries remembered by 0.83.0 for a refused `none` are kept until they expire after 7 days. + +**`off` works across a format conversion.** When the client's format differed from the upstream's (for example an Anthropic client routed to an OpenAI Chat or Responses upstream), `off` removed the reasoning configuration before conversion, so the converted request carried no effort and the upstream reasoned at its default, while the attempt chain showed reasoning as off. + +- OpenAI Chat and Responses upstreams now receive `reasoning_effort: "none"` or `reasoning.effort: "none"`, also when the client turned reasoning on without naming an effort. A refused `none` is handled as above. +- Gemini 2.5 Flash upstreams receive a thinking budget of 0. Gemini models that cannot turn thinking off (2.5 Pro, Gemini 3) are sent no thinking configuration as before, and the conversion now reports the reasoning setting as dropped. +- Anthropic and Bedrock upstreams are unchanged: no thinking configuration and no effort. +- `AttemptView.effort` records the effort written in the converted request. Requests in the upstream's own format are unchanged, and a rule's level is still written the way the upstream's format writes it.