Skip to content

Commit 785dbea

Browse files
committed
fix(provider): apply reasoning_effort to ZAI providers via SetZaiThinking
1 parent 0f038b4 commit 785dbea

4 files changed

Lines changed: 196 additions & 18 deletions

File tree

crates/forge_app/src/dto/openai/transformers/zai_reasoning.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,50 @@ use crate::dto::openai::{Request, ThinkingConfig, ThinkingType};
1313
///
1414
/// - If `reasoning.enabled == Some(true)` → `thinking = {"type": "enabled"}`
1515
/// - If `reasoning.enabled == Some(false)` → `thinking = {"type": "disabled"}`
16-
/// - If `reasoning` is None or `enabled` is None → no `thinking` field added
16+
/// - If `reasoning.enabled == None` and `reasoning.effort ==
17+
/// Some(Effort::None)` → `thinking = {"type": "disabled"}`. The orchestrator
18+
/// preserves this opt-out for z.ai providers so it can be mapped here.
19+
/// - If `reasoning.enabled == None` and `reasoning.effort` is any other value →
20+
/// `thinking = {"type": "enabled"}`
21+
/// - If `reasoning` is None or both `enabled` and `effort` are None → no
22+
/// `thinking` field added
1723
/// - Original `reasoning` field is removed after transformation
1824
///
1925
/// # Note
2026
///
21-
/// Z.ai only supports enabled/disabled state. Other ReasoningConfig fields
22-
/// (`max_tokens`, `effort`, `exclude`) are ignored as they are not supported by
23-
/// z.ai's API.
27+
/// Z.ai only supports enabled/disabled state. `effort` is mapped to that
28+
/// on/off state when `enabled` is unset. Other ReasoningConfig fields
29+
/// (`max_tokens`, `exclude`) are ignored as they are not supported by z.ai's
30+
/// API.
2431
pub struct SetZaiThinking;
2532

2633
impl Transformer for SetZaiThinking {
2734
type Value = Request;
2835

2936
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
30-
// Check if reasoning config exists and has enabled field set
31-
if let Some(reasoning) = request.reasoning.take()
32-
&& let Some(enabled) = reasoning.enabled
33-
{
34-
request.thinking = Some(ThinkingConfig {
35-
r#type: if enabled {
36-
ThinkingType::Enabled
37-
} else {
38-
ThinkingType::Disabled
39-
},
40-
});
37+
if let Some(reasoning) = request.reasoning.take() {
38+
// Effort::None is a strong opt-out and wins over `enabled`, matching
39+
// Context::is_reasoning_supported's resolution order in
40+
// forge_domain. Without this, an explicit `:reasoning-effort none`
41+
// followed later by `enabled: true` (e.g. from defaults) would
42+
// re-enable thinking, contradicting the user's most recent intent.
43+
let enabled = if matches!(reasoning.effort, Some(forge_domain::Effort::None)) {
44+
Some(false)
45+
} else if reasoning.enabled.is_some() {
46+
reasoning.enabled
47+
} else {
48+
reasoning.effort.map(|_| true)
49+
};
50+
51+
if let Some(enabled) = enabled {
52+
request.thinking = Some(ThinkingConfig {
53+
r#type: if enabled {
54+
ThinkingType::Enabled
55+
} else {
56+
ThinkingType::Disabled
57+
},
58+
});
59+
}
4160
}
4261

4362
request
@@ -111,6 +130,79 @@ mod tests {
111130
assert_eq!(actual.reasoning, None);
112131
}
113132

133+
#[test]
134+
fn test_effort_none_overrides_enabled_true() {
135+
// Matches Context::is_reasoning_supported precedence: Effort::None wins
136+
// over enabled: true. Otherwise an opt-out at the effort level would
137+
// be silently re-enabled by a stale `enabled: true` from defaults.
138+
let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {
139+
enabled: Some(true),
140+
effort: Some(forge_domain::Effort::None),
141+
max_tokens: None,
142+
exclude: None,
143+
});
144+
145+
let mut transformer = SetZaiThinking;
146+
let actual = transformer.transform(fixture);
147+
148+
let expected_thinking = Some(ThinkingConfig { r#type: ThinkingType::Disabled });
149+
assert_eq!(actual.thinking, expected_thinking);
150+
assert_eq!(actual.reasoning, None);
151+
}
152+
153+
#[test]
154+
fn test_reasoning_effort_none_converts_to_thinking_disabled() {
155+
let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {
156+
enabled: None,
157+
effort: Some(forge_domain::Effort::None),
158+
max_tokens: None,
159+
exclude: None,
160+
});
161+
162+
let mut transformer = SetZaiThinking;
163+
let actual = transformer.transform(fixture);
164+
165+
let expected_thinking = Some(ThinkingConfig { r#type: ThinkingType::Disabled });
166+
assert_eq!(actual.thinking, expected_thinking);
167+
assert_eq!(actual.reasoning, None);
168+
}
169+
170+
#[test]
171+
fn test_glm5_effort_none_passthrough_converts_to_thinking_disabled() {
172+
let fixture = Request::default()
173+
.model(forge_domain::ModelId::new("glm-5"))
174+
.reasoning(forge_domain::ReasoningConfig {
175+
enabled: None,
176+
effort: Some(forge_domain::Effort::None),
177+
max_tokens: None,
178+
exclude: None,
179+
});
180+
181+
let mut transformer = SetZaiThinking;
182+
let actual = transformer.transform(fixture);
183+
184+
let expected_thinking = Some(ThinkingConfig { r#type: ThinkingType::Disabled });
185+
assert_eq!(actual.thinking, expected_thinking);
186+
assert_eq!(actual.reasoning, None);
187+
}
188+
189+
#[test]
190+
fn test_reasoning_effort_high_converts_to_thinking_enabled() {
191+
let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {
192+
enabled: None,
193+
effort: Some(forge_domain::Effort::High),
194+
max_tokens: None,
195+
exclude: None,
196+
});
197+
198+
let mut transformer = SetZaiThinking;
199+
let actual = transformer.transform(fixture);
200+
201+
let expected_thinking = Some(ThinkingConfig { r#type: ThinkingType::Enabled });
202+
assert_eq!(actual.thinking, expected_thinking);
203+
assert_eq!(actual.reasoning, None);
204+
}
205+
114206
#[test]
115207
fn test_reasoning_with_max_tokens_ignores_max_tokens() {
116208
let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {

crates/forge_app/src/orch.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ use tokio::sync::Notify;
1111
use tracing::warn;
1212

1313
use crate::agent::AgentService;
14-
use crate::transformers::{DropReasoningOnlyMessages, ModelSpecificReasoning};
14+
use crate::transformers::{
15+
DropMessageReasoningDetails, DropReasoningOnlyMessages, ModelSpecificReasoning,
16+
};
1517
use crate::{EnvironmentInfra, TemplateEngine};
1618

19+
fn is_zai_provider_id(id: &ProviderId) -> bool {
20+
id == &ProviderId::ZAI || id == &ProviderId::ZAI_CODING
21+
}
22+
1723
#[derive(Clone, Setters)]
1824
#[setters(into)]
1925
pub struct Orchestrator<S> {
@@ -198,15 +204,26 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc
198204
model_id: &ModelId,
199205
context: Context,
200206
reasoning_supported: bool,
207+
provider_id: &ProviderId,
201208
) -> anyhow::Result<ChatCompletionMessageFull> {
202209
let tool_supported = self.is_tool_supported()?;
203210
let mut transformers = DefaultTransformation::default()
204211
.pipe(SortTools::new(self.agent.tool_order()))
205212
.pipe(NormalizeToolCallArguments::new())
206213
.pipe(TransformToolCalls::new().when(|_| !tool_supported))
207214
.pipe(ImageHandling::new())
208-
// Drop ALL reasoning (including config) when reasoning is not supported by the model
209-
.pipe(DropReasoningDetails.when(|_| !reasoning_supported))
215+
// Drop ALL reasoning (including config) when reasoning is not supported by the model.
216+
// For z.ai providers we still want to clear stale per-message reasoning_details so they
217+
// don't replay against an opted-out turn, but we keep `context.reasoning` so
218+
// `SetZaiThinking` can map it to `thinking: { type: disabled }` downstream.
219+
.pipe(
220+
DropReasoningDetails
221+
.when(|_| !reasoning_supported && !is_zai_provider_id(provider_id)),
222+
)
223+
.pipe(
224+
DropMessageReasoningDetails
225+
.when(|_| !reasoning_supported && is_zai_provider_id(provider_id)),
226+
)
210227
// Strip all reasoning from messages when the model has changed (signatures are
211228
// model-specific and invalid across models). No-op when model is unchanged.
212229
.pipe(ReasoningNormalizer::new(model_id.clone()))
@@ -286,6 +303,7 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc
286303
&model_id,
287304
context.clone(),
288305
context.is_reasoning_supported(),
306+
&self.agent.provider,
289307
)
290308
},
291309
self.sender.as_ref().map(|sender| {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use forge_domain::{Context, ContextMessage, Transformer};
2+
3+
/// Strips assistant `reasoning_details` from every message but leaves
4+
/// `context.reasoning` (the per-turn reasoning config) intact.
5+
///
6+
/// Used on the z.ai provider path: when reasoning is opted out (e.g.
7+
/// `:reasoning-effort none`), the orchestrator must keep `context.reasoning`
8+
/// alive so [`SetZaiThinking`] can map it to `thinking: { type: disabled }`.
9+
/// At the same time, prior assistant `reasoning_details` from earlier turns
10+
/// must NOT replay into the request, otherwise the user's opt-out is
11+
/// undermined and the upstream API may reject unsupported fields.
12+
///
13+
/// Compare to `forge_domain::DropReasoningDetails`, which clears both the
14+
/// per-message details and `context.reasoning`.
15+
pub(crate) struct DropMessageReasoningDetails;
16+
17+
impl Transformer for DropMessageReasoningDetails {
18+
type Value = Context;
19+
20+
fn transform(&mut self, mut context: Self::Value) -> Self::Value {
21+
context.messages.iter_mut().for_each(|message| {
22+
if let ContextMessage::Text(text) = &mut **message {
23+
text.reasoning_details = None;
24+
}
25+
});
26+
context
27+
}
28+
}
29+
30+
#[cfg(test)]
31+
mod tests {
32+
use forge_domain::{ContextMessage, ReasoningConfig, ReasoningFull, Role, TextMessage};
33+
34+
use super::*;
35+
36+
fn make_reasoning() -> Vec<ReasoningFull> {
37+
vec![ReasoningFull {
38+
text: Some("prior thinking".to_string()),
39+
signature: None,
40+
..Default::default()
41+
}]
42+
}
43+
44+
#[test]
45+
fn drops_message_reasoning_details_but_keeps_context_reasoning() {
46+
let reasoning = make_reasoning();
47+
let fixture = Context::default()
48+
.add_message(ContextMessage::Text(
49+
TextMessage::new(Role::Assistant, "hi").reasoning_details(reasoning),
50+
))
51+
.reasoning(ReasoningConfig { enabled: Some(false), ..Default::default() });
52+
53+
let mut transformer = DropMessageReasoningDetails;
54+
let actual = transformer.transform(fixture);
55+
56+
let cleared = actual.messages.iter().all(|entry| match &**entry {
57+
ContextMessage::Text(t) => t.reasoning_details.is_none(),
58+
_ => true,
59+
});
60+
assert!(cleared, "all message reasoning_details should be cleared");
61+
assert!(
62+
actual.reasoning.is_some(),
63+
"context.reasoning must survive so SetZaiThinking can map it"
64+
);
65+
}
66+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
mod compaction;
22
mod dedupe_role;
3+
mod drop_message_reasoning_details;
34
mod drop_reasoning_only_messages;
45
mod drop_role;
56
mod model_specific_reasoning;
67
mod strip_working_dir;
78
mod trim_context_summary;
89

910
pub use compaction::SummaryTransformer;
11+
pub(crate) use drop_message_reasoning_details::DropMessageReasoningDetails;
1012
pub(crate) use drop_reasoning_only_messages::DropReasoningOnlyMessages;
1113
pub(crate) use model_specific_reasoning::ModelSpecificReasoning;

0 commit comments

Comments
 (0)