Skip to content
Merged
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
44 changes: 22 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────
Expand Down
64 changes: 62 additions & 2 deletions crates/tw-dialect/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Value>) -> bool {
let Some(o) = holder
Expand Down Expand Up @@ -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` 那一档想
Expand Down
59 changes: 57 additions & 2 deletions crates/tw-dialect/src/think.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,30 @@ pub fn effort_rejection(status: u16, error_body: &str, sent: Effort) -> Option<E
}

/// 发出去的是 `none`(OpenAI 的「关掉推理」,见 [`crate::params::disable_thinking`]),上游这个
/// 错误是不是因为它:判据和 [`effort_rejection`] 一样,`none` 当作发出去的那一档。是的话重发时
/// 去掉这个字段
/// 错误是不是因为它:判据和 [`effort_rejection`] 一样,`none` 当作发出去的那一档。重发时怎么写
/// 见 [`none_fix`]
pub fn none_rejection(status: u16, error_body: &str) -> 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<EffortFix> {
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
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-gateway/src/effort_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`]),
//! 同一家改一档、或者去掉强度马上再发一次。**重发成了才记**:(上游,发给它的模型名,发出去的
//! 格式)不认哪一档、改用什么。往后发给它的请求要是还写着那一档,发之前直接改,不用每次都先被
//! 拒一次。重发又被拒了的不记;记着的改法自己被拒了、重发成了用的正是原来那一档的,那一条删掉。
Expand Down
Loading
Loading